@derwinjs/db 0.10.0 → 0.12.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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * createPrismaRumSampleStore — Prisma-backed implementation of the SDK
3
+ * RumSampleStore contract introduced by QAP-113.
4
+ *
5
+ * Sprint 11 Phase 3 (Test Surfaces — RUM ingestion). Samples are stored as
6
+ * one row per insert against the `RumSample` model. There is no uniqueness
7
+ * constraint on (projectId, metric) — a single page-load may emit multiple
8
+ * samples per metric, and operators need the full distribution to derive
9
+ * percentiles.
10
+ *
11
+ * Tenant isolation: every method scopes by projectId. Pattern D applies —
12
+ * unknown / wrong-project lookups return null from `getBaseline` rather
13
+ * than throwing or leaking existence (defense-in-depth against tenant
14
+ * enumeration). The "no samples in window" return is also null, so
15
+ * cross-tenant probes look identical to "no data this window".
16
+ *
17
+ * Percentile math: derived in-memory via array-index math after the
18
+ * `findMany` returns a value-sorted result set. No third-party stats
19
+ * library — RumSample row sets per (projectId, metric, window) cap at the
20
+ * tens-of-thousands order of magnitude, which fits comfortably in process
21
+ * memory for the percentile read.
22
+ */
23
+ // ─── Error class ─────────────────────────────────────────────────────────
24
+ /**
25
+ * Errors thrown by the Prisma-backed RumSampleStore. The store-level
26
+ * errors are kept inside @derwinjs/db (NOT promoted to @derwinjs/sdk)
27
+ * because they reflect storage-layer specifics; the route layer maps
28
+ * them to the operator-facing WebVitalsIngestorError before crossing
29
+ * the SDK boundary.
30
+ *
31
+ * The discriminating `code` field lets callers route handling:
32
+ * - `invalid_input` — caller bug rejected at the factory boundary; do
33
+ * not retry.
34
+ * - `io_failed` — underlying Prisma call raised; transient — retry
35
+ * or escalate.
36
+ */
37
+ export class RumSampleStoreError extends Error {
38
+ code;
39
+ cause;
40
+ constructor(code, message, cause) {
41
+ super(message);
42
+ this.code = code;
43
+ this.cause = cause;
44
+ this.name = 'RumSampleStoreError';
45
+ Object.setPrototypeOf(this, RumSampleStoreError.prototype);
46
+ }
47
+ }
48
+ // ─── Validation ──────────────────────────────────────────────────────────
49
+ function assertNonEmpty(value, fieldName) {
50
+ if (typeof value !== 'string' || value.trim() === '') {
51
+ throw new RumSampleStoreError('invalid_input', `createPrismaRumSampleStore: ${fieldName} is required and non-empty`);
52
+ }
53
+ }
54
+ function assertFiniteNumber(value, fieldName) {
55
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
56
+ throw new RumSampleStoreError('invalid_input', `createPrismaRumSampleStore: ${fieldName} must be a finite number`);
57
+ }
58
+ }
59
+ function assertNonNegativeMs(value, fieldName) {
60
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
61
+ throw new RumSampleStoreError('invalid_input', `createPrismaRumSampleStore: ${fieldName} must be a non-negative finite number`);
62
+ }
63
+ }
64
+ // ─── Percentile helper ──────────────────────────────────────────────────
65
+ /**
66
+ * Nearest-rank percentile over a value-sorted ascending array. Mirrors the
67
+ * convention web-vitals.js + most RUM analytics tools use:
68
+ *
69
+ * index = ceil((p / 100) * N) - 1, clamped to [0, N-1].
70
+ *
71
+ * For a single-element array all percentiles return that element. For
72
+ * pre-sorted ascending arrays this is the cheapest correct percentile; the
73
+ * caller is responsible for sorting before passing the array in.
74
+ */
75
+ function percentile(sortedAsc, p) {
76
+ const n = sortedAsc.length;
77
+ if (n === 0) {
78
+ throw new RumSampleStoreError('invalid_input', 'createPrismaRumSampleStore: percentile() called on empty array (caller bug)');
79
+ }
80
+ const idx = Math.max(0, Math.min(n - 1, Math.ceil((p / 100) * n) - 1));
81
+ // idx is in [0, n-1] and n >= 1, so the slot is always set. Runtime-guard
82
+ // form (memory rule 13) instead of `!` or `as number` — both are banned by
83
+ // @typescript-eslint/no-non-null-assertion + non-nullable-type-assertion-style.
84
+ const value = sortedAsc[idx];
85
+ if (value === undefined) {
86
+ throw new RumSampleStoreError('invalid_input', `createPrismaRumSampleStore: percentile() unreachable branch — idx=${String(idx)} of ${String(n)}`);
87
+ }
88
+ return value;
89
+ }
90
+ // ─── Factory ─────────────────────────────────────────────────────────────
91
+ export function createPrismaRumSampleStore(config) {
92
+ const { prisma } = config;
93
+ return {
94
+ async insert(input) {
95
+ assertNonEmpty(input.projectId, 'projectId');
96
+ assertNonEmpty(input.metric, 'metric');
97
+ assertFiniteNumber(input.value, 'value');
98
+ assertNonEmpty(input.rating, 'rating');
99
+ assertNonEmpty(input.url, 'url');
100
+ try {
101
+ const row = await prisma.rumSample.create({
102
+ data: {
103
+ projectId: input.projectId,
104
+ metric: input.metric,
105
+ value: input.value,
106
+ rating: input.rating,
107
+ url: input.url,
108
+ deviceType: input.deviceType ?? null,
109
+ sessionId: input.sessionId ?? null,
110
+ },
111
+ select: { id: true },
112
+ });
113
+ return { id: row.id };
114
+ }
115
+ catch (err) {
116
+ throw new RumSampleStoreError('io_failed', `createPrismaRumSampleStore: insert raised: ${err.message}`, err);
117
+ }
118
+ },
119
+ async getBaseline(input) {
120
+ assertNonEmpty(input.projectId, 'projectId');
121
+ assertNonEmpty(input.metric, 'metric');
122
+ assertNonNegativeMs(input.sinceMs, 'sinceMs');
123
+ const windowEnd = new Date();
124
+ const windowStart = new Date(windowEnd.getTime() - input.sinceMs);
125
+ let rows;
126
+ try {
127
+ rows = await prisma.rumSample.findMany({
128
+ where: {
129
+ projectId: input.projectId,
130
+ metric: input.metric,
131
+ capturedAt: { gte: windowStart },
132
+ },
133
+ orderBy: { value: 'asc' },
134
+ select: { value: true },
135
+ });
136
+ }
137
+ catch (err) {
138
+ throw new RumSampleStoreError('io_failed', `createPrismaRumSampleStore: getBaseline raised: ${err.message}`, err);
139
+ }
140
+ if (rows.length === 0)
141
+ return null;
142
+ const values = rows.map((r) => r.value);
143
+ return {
144
+ metric: input.metric,
145
+ p50: percentile(values, 50),
146
+ p75: percentile(values, 75),
147
+ p95: percentile(values, 95),
148
+ sampleCount: values.length,
149
+ windowStart,
150
+ windowEnd,
151
+ };
152
+ },
153
+ };
154
+ }
155
+ //# sourceMappingURL=rum-sample-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rum-sample-store.js","sourceRoot":"","sources":["../src/rum-sample-store.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@derwinjs/db",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Prisma schema + migrations for Derwin's own Postgres. 14 models, project-namespaced. Per ADR-0005. Ships its own generated Prisma client (multi-platform binaries) for cross-consumer compatibility.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@prisma/client": "^5.22.0",
36
- "@derwinjs/sdk": "0.10.0",
37
- "@derwinjs/core": "0.10.0"
36
+ "@derwinjs/core": "0.12.0",
37
+ "@derwinjs/sdk": "0.12.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@vitest/coverage-v8": "^2.1.9",
@@ -0,0 +1,31 @@
1
+ -- Sprint 11 Phase 3 (QAP-113) — RUM / Web Vitals samples.
2
+ --
3
+ -- Stores consumer-pushed Real User Monitoring (RUM) samples emitted by the
4
+ -- consumer app's web-vitals hooks. One row per (projectId, metric, capturedAt)
5
+ -- — there is no uniqueness constraint because the same metric + URL may fire
6
+ -- many times per session. Baseline statistics (p50/p75/p95) are derived on
7
+ -- demand from this row set rather than maintained as a rolling aggregate.
8
+ --
9
+ -- Idempotent (IF NOT EXISTS) — safe to re-run on environments where the
10
+ -- table or index already exists.
11
+
12
+ CREATE TABLE IF NOT EXISTS "derwin"."rum_samples" (
13
+ "id" TEXT NOT NULL,
14
+ "projectId" TEXT NOT NULL,
15
+ "metric" TEXT NOT NULL,
16
+ "value" DOUBLE PRECISION NOT NULL,
17
+ "rating" TEXT NOT NULL,
18
+ "url" TEXT NOT NULL,
19
+ "deviceType" TEXT,
20
+ "sessionId" TEXT,
21
+ "capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
22
+ CONSTRAINT "rum_samples_pkey" PRIMARY KEY ("id")
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS "rum_samples_projectId_metric_capturedAt_idx"
26
+ ON "derwin"."rum_samples"("projectId", "metric", "capturedAt" DESC);
27
+
28
+ ALTER TABLE "derwin"."rum_samples"
29
+ ADD CONSTRAINT "rum_samples_projectId_fkey"
30
+ FOREIGN KEY ("projectId") REFERENCES "derwin"."projects"("id")
31
+ ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,42 @@
1
+ -- Sprint 11 Phase 5 (QAP-115) — MilestoneEvent foundation.
2
+ --
3
+ -- Cross-product tracking timeline data source. Backs the Tracking Timeline
4
+ -- Gantt UI (Phase 6) plus future cross-product coherence features (e.g.,
5
+ -- conflict alerts when a release lands inside another project's freeze
6
+ -- window). The spec's `tenantId?` field is intentionally omitted in v1 —
7
+ -- per the Derwin multi-tenant boundary rule (each consumer = ONE Project
8
+ -- row), milestones are project-scoped only; if a downstream consumer ever
9
+ -- needs intra-project tenant slicing it can be added as an optional column
10
+ -- in a future migration without breaking the existing queries.
11
+ --
12
+ -- `kind` is held as a String column rather than a Prisma enum so consumers
13
+ -- can emit custom kinds (alongside the canonical
14
+ -- release / freeze / demo / launch) without requiring a migration. The
15
+ -- conflict-detection helpers in `@derwinjs/core` validate the value at
16
+ -- runtime where it matters.
17
+ --
18
+ -- Two indexes:
19
+ -- - (projectId, startsAt ASC) supports the per-project chronological
20
+ -- listing the Phase 6 Gantt issues on every read.
21
+ -- - (projectId, kind) supports the swimlane-by-kind grouping the Gantt
22
+ -- uses to lay out releases / freezes / demos / launches.
23
+
24
+ CREATE TABLE "derwin"."milestone_events" (
25
+ "id" TEXT NOT NULL,
26
+ "projectId" TEXT NOT NULL,
27
+ "name" TEXT NOT NULL,
28
+ "description" TEXT,
29
+ "startsAt" TIMESTAMP(3) NOT NULL,
30
+ "endsAt" TIMESTAMP(3),
31
+ "kind" TEXT NOT NULL,
32
+ "createdBy" TEXT NOT NULL,
33
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+ "updatedAt" TIMESTAMP(3) NOT NULL,
35
+ CONSTRAINT "milestone_events_pkey" PRIMARY KEY ("id")
36
+ );
37
+
38
+ CREATE INDEX "milestone_events_projectId_startsAt_idx" ON "derwin"."milestone_events"("projectId", "startsAt" ASC);
39
+
40
+ CREATE INDEX "milestone_events_projectId_kind_idx" ON "derwin"."milestone_events"("projectId", "kind");
41
+
42
+ ALTER TABLE "derwin"."milestone_events" ADD CONSTRAINT "milestone_events_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "derwin"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -151,6 +151,8 @@ model Project {
151
151
  visualBaselines VisualBaseline[]
152
152
  contractBaselines ContractBaseline[]
153
153
  tenantFuzzConfig TenantFuzzConfig?
154
+ rumSamples RumSample[]
155
+ milestoneEvents MilestoneEvent[]
154
156
 
155
157
  @@map("projects")
156
158
  @@schema("derwin")
@@ -1089,3 +1091,83 @@ model TenantFuzzConfig {
1089
1091
  @@map("tenant_fuzz_configs")
1090
1092
  @@schema("derwin")
1091
1093
  }
1094
+
1095
+ // ═══════════════════════════════════════════════════════════════════════════
1096
+ // QAP-113 (Sprint 11 Phase 3) — RUM / Web Vitals samples
1097
+ // ═══════════════════════════════════════════════════════════════════════════
1098
+ //
1099
+ // Stores consumer-pushed Real User Monitoring (RUM) samples emitted by the
1100
+ // consumer app's web-vitals hooks. Unlike Sprint 10/11 scanner surfaces this
1101
+ // is INGESTION — the data is pushed from client devices, not produced by a
1102
+ // Derwin-spawned scanner. One row per individual sample; baseline statistics
1103
+ // (p50/p75/p95) are derived on demand from this row set rather than
1104
+ // maintained as a rolling aggregate.
1105
+ //
1106
+ // The Prisma-backed implementation lives at
1107
+ // packages/db/src/rum-sample-store.ts; the SDK contract is in
1108
+ // @derwinjs/sdk (RumSampleStore in
1109
+ // packages/sdk/src/types/rum-sample-store.ts) and the ingest contract
1110
+ // (WebVitalsIngestor) in packages/sdk/src/types/web-vitals-ingestor.ts.
1111
+ //
1112
+ // `metric` and `rating` are held as String columns rather than Prisma enums
1113
+ // so consumers can emit custom metrics (alongside the canonical
1114
+ // CLS / FCP / INP / LCP / TTFB) without requiring a migration. The factory
1115
+ // validates the value at runtime where it matters.
1116
+
1117
+ model RumSample {
1118
+ id String @id @default(cuid())
1119
+ projectId String
1120
+ metric String // 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB' or custom
1121
+ value Float
1122
+ rating String // 'good' | 'needs-improvement' | 'poor'
1123
+ url String
1124
+ deviceType String?
1125
+ sessionId String?
1126
+ capturedAt DateTime @default(now())
1127
+
1128
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
1129
+
1130
+ @@index([projectId, metric, capturedAt(sort: Desc)])
1131
+ @@map("rum_samples")
1132
+ @@schema("derwin")
1133
+ }
1134
+
1135
+ // ═══════════════════════════════════════════════════════════════════════════
1136
+ // QAP-115 (Sprint 11 Phase 5) — Cross-product tracking timeline (MilestoneEvent)
1137
+ // ═══════════════════════════════════════════════════════════════════════════
1138
+ //
1139
+ // Foundation for the Tracking Timeline Gantt UI shipped in Sprint 11 Phase 6.
1140
+ // One row per scheduled or in-progress milestone; ranges (releases, freeze
1141
+ // windows) carry both `startsAt` + `endsAt`; point-in-time milestones (demos,
1142
+ // launches) carry only `startsAt`. `kind` is intentionally a free-form String
1143
+ // rather than a Prisma enum so consumers can emit custom kinds (the conflict-
1144
+ // detection helpers in @derwinjs/core treat unknown kinds as "no conflict
1145
+ // computed against this kind" rather than throwing).
1146
+ //
1147
+ // The Prisma-backed implementation lives at
1148
+ // packages/db/src/milestone-event-store.ts; the SDK contract is in
1149
+ // @derwinjs/sdk (MilestoneEventStore in
1150
+ // packages/sdk/src/types/milestone-event-store.ts).
1151
+ //
1152
+ // Indexes mirror the two read paths: chronological per project (for the
1153
+ // Gantt's primary read) and per-kind per project (for swimlane grouping).
1154
+
1155
+ model MilestoneEvent {
1156
+ id String @id @default(cuid())
1157
+ projectId String
1158
+ name String
1159
+ description String? @db.Text
1160
+ startsAt DateTime
1161
+ endsAt DateTime?
1162
+ kind String // 'release' | 'freeze' | 'demo' | 'launch' | custom
1163
+ createdBy String
1164
+ createdAt DateTime @default(now())
1165
+ updatedAt DateTime @updatedAt
1166
+
1167
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
1168
+
1169
+ @@index([projectId, startsAt(sort: Asc)])
1170
+ @@index([projectId, kind])
1171
+ @@map("milestone_events")
1172
+ @@schema("derwin")
1173
+ }