Files
website/drizzle/20261024174805_hardware/migration.sql
Stan Runge 085a30bec8
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
pc: PC setup tracker at /trackers/pc on the hardware schema
- model the existing hardware schema in Drizzle (devices, components, new sensitivities)
- idempotent reconciling migration: new columns, checks, setup_overview view, grants, meta.tables
- seed: main PC labels/hostnames, planned AMD X3D + RTX 5090 build, stan-ovh (OVH RISE-S),
  other devices, sensitivity placeholders (no invented cm/360)
- owner-only add/edit via modals; public view hides notes/order numbers
- trackers dropdown entry + icon, seo/title/sitemap, owner write prefix
2026-10-24 17:55:48 +00:00

291 lines
18 KiB
SQL

-- Hand-reconciled: the hardware schema already existed (hand-made 2026-10-22 from Stan's list +
-- order mails, see ~/dev/hardware/schema.sql; prices later moved to cents). This brings an existing
-- schema to the Drizzle model, and creates it from scratch on an empty database.
CREATE SCHEMA IF NOT EXISTS "hardware";
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."devices" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."devices_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"name" text NOT NULL UNIQUE,
"label" text,
"hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
"kind" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"brand" text,
"model" text,
"specs" jsonb DEFAULT '{}' NOT NULL,
"price_cents" integer,
"monthly_cents" integer,
"budget_cents" integer,
"shop" text,
"purchase_date" date,
"url" text,
"image_url" text,
"sort_order" integer DEFAULT 100 NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other')),
CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold')),
CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."components" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."components_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"device_id" bigint NOT NULL,
"category" text NOT NULL,
"brand" text,
"model" text NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"specs" jsonb DEFAULT '{}' NOT NULL,
"unit_price_cents" integer,
"total_price_cents" integer,
"shop" text,
"order_number" text,
"order_date" date,
"warranty_until" date,
"invoice_number" text,
"status" text DEFAULT 'in_use' NOT NULL,
"verified" boolean DEFAULT true NOT NULL,
"dpi" integer,
"measured_dpi" integer,
"url" text,
"image_url" text,
"notes" text,
"source" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT("device_id","model","order_number"),
CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other')),
CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold')),
CONSTRAINT "components_quantity_check" CHECK (quantity > 0),
CONSTRAINT "components_unit_price_cents_check" CHECK (unit_price_cents >= 0),
CONSTRAINT "components_total_price_cents_check" CHECK (total_price_cents >= 0),
CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."sensitivities" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."sensitivities_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"game" text NOT NULL UNIQUE,
"cm_per_360" numeric(7,2),
"yaw" numeric(10,6),
"in_game_sens" numeric(10,4),
"mouse_id" bigint,
"fov" text,
"notes" text,
"sort_order" integer DEFAULT 100 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sensitivities_cm_check" CHECK (cm_per_360 is null or cm_per_360 > 0),
CONSTRAINT "sensitivities_yaw_check" CHECK (yaw is null or yaw > 0)
);
--> statement-breakpoint
-- Existing tables: new columns.
ALTER TABLE "hardware"."devices"
ADD COLUMN IF NOT EXISTS "label" text,
ADD COLUMN IF NOT EXISTS "hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL,
ADD COLUMN IF NOT EXISTS "brand" text,
ADD COLUMN IF NOT EXISTS "model" text,
ADD COLUMN IF NOT EXISTS "specs" jsonb DEFAULT '{}' NOT NULL,
ADD COLUMN IF NOT EXISTS "price_cents" integer,
ADD COLUMN IF NOT EXISTS "monthly_cents" integer,
ADD COLUMN IF NOT EXISTS "budget_cents" integer,
ADD COLUMN IF NOT EXISTS "shop" text,
ADD COLUMN IF NOT EXISTS "purchase_date" date,
ADD COLUMN IF NOT EXISTS "url" text,
ADD COLUMN IF NOT EXISTS "image_url" text,
ADD COLUMN IF NOT EXISTS "sort_order" integer DEFAULT 100 NOT NULL,
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
--> statement-breakpoint
ALTER TABLE "hardware"."components"
ADD COLUMN IF NOT EXISTS "dpi" integer,
ADD COLUMN IF NOT EXISTS "measured_dpi" integer,
ADD COLUMN IF NOT EXISTS "url" text,
ADD COLUMN IF NOT EXISTS "image_url" text;
--> statement-breakpoint
-- Checks: widen kinds/categories/statuses (drop + re-add by name).
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_kind_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other'));
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_status_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold'));
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_cents_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_category_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other'));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_status_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold'));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_dpi_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0));
--> statement-breakpoint
-- Dedupe key: unique index -> constraint (same name, same NULLS NOT DISTINCT semantics);
-- device FK -> Drizzle's name.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_dedupe_uq' AND connamespace = 'hardware'::regnamespace) THEN
DROP INDEX IF EXISTS "hardware"."components_dedupe_uq";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT ("device_id","model","order_number");
END IF;
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."components" RENAME CONSTRAINT "components_device_id_fkey" TO "components_device_id_devices_id_fkey";
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_devices_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "hardware"."devices"("id") ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sensitivities_mouse_id_components_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."sensitivities" ADD CONSTRAINT "sensitivities_mouse_id_components_id_fkey" FOREIGN KEY ("mouse_id") REFERENCES "hardware"."components"("id") ON DELETE SET NULL;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "components_device_idx" ON "hardware"."components" ("device_id");
CREATE INDEX IF NOT EXISTS "components_category_idx" ON "hardware"."components" ("category");
CREATE INDEX IF NOT EXISTS "components_specs_gin" ON "hardware"."components" USING gin ("specs");
--> statement-breakpoint
-- Hand-written: updated_at triggers, overview view, grants, GUI registration, seed.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['devices','components','sensitivities'] LOOP
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'touch_updated_at' AND tgrelid = format('hardware.%I', t)::regclass) THEN
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON hardware.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END IF;
END LOOP;
END $$;
--> statement-breakpoint
DROP VIEW IF EXISTS "hardware"."setup_overview";
--> statement-breakpoint
CREATE VIEW "hardware"."setup_overview" AS
SELECT d.name AS device,
d.label AS device_label,
d.kind AS device_kind,
d.status AS device_status,
c.category,
c.brand,
c.model,
c.quantity,
c.total_price_cents,
round(c.total_price_cents::numeric / 100.0, 2) AS total_price_eur,
c.shop,
c.order_date,
c.warranty_until,
(c.warranty_until >= current_date) AS under_warranty,
c.status,
c.verified,
c.specs,
c.dpi,
c.measured_dpi,
c.notes,
c.order_number,
c.invoice_number,
c.source,
c.id AS component_id
FROM hardware.components c
JOIN hardware.devices d ON d.id = c.device_id
ORDER BY d.sort_order, d.name,
array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','mouse','keyboard','headset','audio','controller','accessory','other'], c.category),
c.order_date NULLS LAST, c.model;
--> statement-breakpoint
COMMENT ON VIEW "hardware"."setup_overview" IS 'Flat view of every component per device (build order), with a live under_warranty flag. status = ''in_use'' is the current setup, ''planned'' the upgrade plan.';
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA hardware TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA hardware TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA hardware TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA hardware TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA hardware TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('hardware','devices','hardware-devices','Hardware devices','core',true,false,1000),
('hardware','components','hardware-components','Hardware components','core',true,false,1000),
('hardware','sensitivities','hardware-sensitivities','Game sensitivities (cm/360)','core',true,false,1000)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;
--> statement-breakpoint
-- ===== Seed (idempotent). Facts from Stan's own list, order mails and what he told Daisy;
-- unknowns stay null. No home address in public-facing notes.
INSERT INTO hardware.devices (name, kind) VALUES ('stan-pc','desktop'), ('old-pc-2021','desktop')
ON CONFLICT (name) DO NOTHING;
--> statement-breakpoint
UPDATE hardware.devices SET
label = 'Main PC', hostnames = '{stan-arch,stan-win11}', status = 'active', model = 'Custom build',
shop = 'Megekko', sort_order = 10,
notes = 'Stan''s main PC: Lian Li O11 Dynamic XL ROG White, i9-12900KF, RX 7900 XT, 10 Lian Li fans around the case + 3 NZXT Aer on the radiator. Dual-boots Arch (stan-arch) and Windows 11 + WSL2 (stan-win11). Desk peripherals and the Odyssey G9 are tracked here. Mostly bought at Megekko 2021-2022.'
WHERE name = 'stan-pc' AND label IS NULL;
--> statement-breakpoint
UPDATE hardware.devices SET
label = 'Previous PC (2021)', status = 'retired', model = 'Budget build', shop = 'Megekko',
purchase_date = '2021-01-31', sort_order = 90
WHERE name = 'old-pc-2021' AND label IS NULL;
--> statement-breakpoint
INSERT INTO hardware.devices (name, label, hostnames, kind, status, brand, model, specs, price_cents, monthly_cents, budget_cents, shop, purchase_date, url, sort_order, notes) VALUES
('stan-pc-next','Next PC (planned)','{}','desktop','planned',NULL,'High-end AMD + RTX 5090 build','{}',NULL,NULL,1000000,NULL,NULL,NULL,15,
'Planned upgrade, budget up to about EUR 10k: top AMD X3D CPU, RTX 5090, fast DDR5, PCIe Gen5 SSD, high-end motherboard and a cooler that fits. Parts not picked yet.'),
('stan-ovh','Server','{}','server','active','OVHcloud','RISE-S',
'{"cpu":"AMD Ryzen 7 9700X","ram":"64 GB DDR5 ECC","storage":"2x 512 GB NVMe (software RAID1)","os":"Ubuntu 26.04"}',
NULL,6174,NULL,'OVHcloud','2026-10-24','https://www.ovhcloud.com/en/bare-metal/rise/',20,
'Dedicated server, prod since 2026-10-24 (replaced stan-vm). EUR 61.74/month excl. VAT. Runs Postgres, Gitea, the website and the bots.'),
('stan-vm','Old VM','{}','vm','retired','Hetzner Cloud',NULL,'{"vcpu":4,"ram":"8 GB"}',NULL,2000,NULL,'Hetzner',NULL,NULL,80,
'Old always-on Hetzner VM (about EUR 20/month, bought before the price rises). Replaced by stan-ovh on 2026-10-24; stopped, kept as a fallback until Stan cancels it.'),
('stan-macbookpro','MacBook Pro','{}','laptop','active','Apple','MacBook Pro','{}',NULL,NULL,NULL,NULL,NULL,NULL,30,
'Exact model/chip not recorded yet. An M5 Max MacBook Pro is planned.'),
('stan-pixel','Phone','{}','phone','active','Google','Pixel 11 Pro XL','{"storage":"256 GB"}',NULL,NULL,NULL,'Belsimpel','2026-10-18',NULL,40,
'Main phone since 2026-10-19, stock Android. About EUR 1,299 SIM-free (exact amount not recorded).'),
('stan-iphone','Old phone','{}','phone','spare','Apple','iPhone 14 Pro Max','{}',NULL,NULL,NULL,NULL,NULL,NULL,45,
'Previous phone, kept (weak battery and speed).'),
('stan-ipad','iPad','{}','tablet','active','Apple','iPad Air 13-inch (M2)','{"screen_in":13,"chip":"Apple M2"}',NULL,NULL,NULL,NULL,NULL,NULL,50,
'With a keyboard case; used for typing on the go and Termius SSH.'),
('cargill-laptop','Work laptop','{}','laptop','active',NULL,NULL,'{"ram":"16 GB"}',NULL,NULL,NULL,NULL,NULL,NULL,60,
'Cargill work laptop (not personal; Windows + WSL2). Model not recorded.')
ON CONFLICT (name) DO NOTHING;
--> statement-breakpoint
-- Peripherals get real categories instead of "accessory".
UPDATE hardware.components SET category = 'mouse' WHERE category = 'accessory' AND model ILIKE 'MX Master%';
--> statement-breakpoint
UPDATE hardware.components SET category = 'keyboard' WHERE category = 'accessory' AND (model ILIKE 'Alloy Origins%' OR model ILIKE 'Pudding Keycaps%' OR model ILIKE 'UwU%');
--> statement-breakpoint
UPDATE hardware.components SET category = 'controller' WHERE category = 'accessory' AND brand = 'Thrustmaster';
--> statement-breakpoint
UPDATE hardware.components SET category = 'audio' WHERE category = 'accessory' AND model ILIKE '%mic boom arm%';
--> statement-breakpoint
UPDATE hardware.components SET notes = replace(notes, ', delivered 2026-03-03 to Houtenstraat 15 Gouda.', ', delivered 2026-03-03.') WHERE notes LIKE '%Houtenstraat%';
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, notes, source)
SELECT d.id, v.category, v.brand, v.model, v.quantity, v.specs::jsonb, v.status, true, v.notes, v.source
FROM (VALUES
('stan-ovh','cpu','AMD','Ryzen 7 9700X',1,'{"cores":8,"threads":16,"socket":"AM5"}','in_use',NULL,'manual'),
('stan-ovh','ram',NULL,'64 GB DDR5 ECC',1,'{"type":"DDR5 ECC","capacity_gb":64}','in_use',NULL,'manual'),
('stan-ovh','storage',NULL,'512 GB NVMe SSD',2,'{"interface":"NVMe","capacity_gb":512,"raid":"software RAID1"}','in_use','Mirrored (RAID1), about 440 GB usable for data.','manual'),
('stan-pc-next','cpu','AMD','Top Ryzen X3D (model TBD)',1,'{"socket":"AM5"}','planned','Pick the fastest gaming X3D chip available when buying.','plan'),
('stan-pc-next','gpu','NVIDIA','GeForce RTX 5090',1,'{"vram_gb":32}','planned','Board partner TBD.','plan'),
('stan-pc-next','motherboard',NULL,'High-end AM5 motherboard (TBD)',1,'{"socket":"AM5"}','planned',NULL,'plan'),
('stan-pc-next','ram',NULL,'Fast DDR5 kit (TBD)',1,'{"type":"DDR5"}','planned',NULL,'plan'),
('stan-pc-next','storage',NULL,'PCIe 5.0 NVMe SSD (TBD)',1,'{"interface":"PCIe 5.0 x4 NVMe"}','planned',NULL,'plan'),
('stan-pc-next','cpu_cooler',NULL,'Cooler for the new CPU (TBD)',1,'{}','planned','Must fit the new socket and the O11 Dynamic XL (or the new case).','plan')
) AS v(device, category, brand, model, quantity, specs, status, notes, source)
JOIN hardware.devices d ON d.name = v.device
ON CONFLICT (device_id, model, order_number) DO NOTHING;
--> statement-breakpoint
-- Games Stan plays; cm/360 left empty for him to fill. CS2's yaw (0.022 deg/count) is the
-- Source engine constant, so its in-game value is computed from a mouse's measured DPI.
INSERT INTO hardware.sensitivities (game, yaw, sort_order) VALUES
('Counter-Strike 2', 0.022, 10),
('Call of Duty', NULL, 20),
('osu!', NULL, 30),
('Sea of Thieves', NULL, 40)
ON CONFLICT (game) DO NOTHING;