-- 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;