printhive/db/schema.sql
dlawler489 2635a1e9f0 Pick spools from the maker's filament list, and name slot colours
Adding a spool no longer means typing a hex code: choose "PLA Basic — Jade
White (10100)" and the material, colour name, swatch and filament code fill
themselves. Slot colours on the model page are named where they match, so a
slot reads "#586579 · Basic Blue Gray" instead of bare hex.

The filament code is stored per spool. It isn't decoration — the AMS reports
filament IDs, so this is what a later read-only printer connection will match a
loaded tray against.

db/bambu-filaments.json holds Bambu Lab's 325 filaments (material, type, colour
name, code, colours), included by kind permission of 3dfilamentprofiles.com,
whose terms otherwise reserve the data. The permission and source are recorded
in the file itself as well as the README, so nobody reuses it by accident.

Naming uses a much tighter threshold than matching (12 against 50): identifying
a colour and substituting for one are different questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:01:09 +10:00

291 lines
13 KiB
SQL

-- STL Library Manager schema
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a
-- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...).
-- Per-license commercial-use policy. Auto-registered as licenses are seen;
-- editable in Settings. commercial: true=sellable, false=non-commercial, null=unknown.
CREATE TABLE IF NOT EXISTS license_rules (
name TEXT PRIMARY KEY,
commercial BOOLEAN
);
-- App users (email/password auth).
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One-time password-reset tokens (token stored hashed).
CREATE TABLE IF NOT EXISTS password_resets (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token_hash);
-- Simple key/value app settings (API tokens, site auth). Editable from the UI.
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS designers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
source_site TEXT, -- thingiverse | printables | makerworld | ...
profile_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (name, source_site)
);
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
date_created TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS models (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
source_site TEXT, -- where it came from, null for manual upload
source_url TEXT, -- original page URL (for attribution)
source_id TEXT, -- the site's id for the thing/model
license TEXT,
designer_id INTEGER REFERENCES designers(id) ON DELETE SET NULL,
thumbnail_path TEXT,
print_settings JSONB NOT NULL DEFAULT '{}'::jsonb,
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
file_name TEXT NOT NULL, -- on-disk name (unique suffix)
original_name TEXT NOT NULL, -- name as downloaded/uploaded
file_path TEXT NOT NULL, -- path on disk
format TEXT NOT NULL, -- stl | 3mf | obj | step | gcode | zip | ...
size BIGINT NOT NULL DEFAULT 0,
volume DOUBLE PRECISION, -- cm^3 (from node-stl), when computable
bbox JSONB, -- {x,y,z} bounding box
facets INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS model_projects (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
PRIMARY KEY (model_id, project_id)
);
CREATE TABLE IF NOT EXISTS model_tags (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (model_id, tag_id)
);
-- Cached GLB conversion of a 3MF, for in-browser 3D preview.
ALTER TABLE files ADD COLUMN IF NOT EXISTS glb_path TEXT;
-- Whether the model's license permits commercial use (selling). true/false/null.
ALTER TABLE models ADD COLUMN IF NOT EXISTS commercial_use BOOLEAN;
CREATE INDEX IF NOT EXISTS idx_files_model ON files(model_id);
CREATE INDEX IF NOT EXISTS idx_model_projects_p ON model_projects(project_id);
CREATE INDEX IF NOT EXISTS idx_model_tags_t ON model_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_id);
-- Content + geometry fingerprints for the duplicate finder (filled in the
-- background by lib/fingerprint.js).
ALTER TABLE files ADD COLUMN IF NOT EXISTS sha256 TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_hash TEXT;
-- null = not checked yet; ok | error | missing | na (format has no geometry fingerprint)
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_status TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS fingerprinted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_files_sha256 ON files(sha256);
CREATE INDEX IF NOT EXISTS idx_files_geometry ON files(geometry_hash);
-- Free-form notes per model.
ALTER TABLE models ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
-- Soft delete: set when a model is moved to the trash.
ALTER TABLE models ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_models_deleted ON models(deleted_at);
-- Archive files (zip/7z/rar/tar) are indexed in the background:
-- null = not yet, ok | error (with archive_error).
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_status TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_error TEXT;
-- Files inside archives, so they can be browsed, previewed and matched as duplicates.
CREATE TABLE IF NOT EXISTS archive_entries (
id SERIAL PRIMARY KEY,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- path inside the archive, as the archive stores it
format TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
sha256 TEXT,
geometry_hash TEXT,
geometry_status TEXT, -- same states as files.geometry_status
fingerprinted_at TIMESTAMPTZ,
UNIQUE (file_id, path)
);
CREATE INDEX IF NOT EXISTS idx_entries_sha256 ON archive_entries(sha256);
CREATE INDEX IF NOT EXISTS idx_entries_geometry ON archive_entries(geometry_hash);
-- Background thumbnails: null = not tried yet, none = nothing to make one from
-- (left to the browser), error. Cleared when files are added.
ALTER TABLE models ADD COLUMN IF NOT EXISTS thumbnail_status TEXT;
-- Duplicate groups marked "not duplicates" (signature = sorted model ids).
CREATE TABLE IF NOT EXISTS duplicate_dismissals (
signature TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Where a model's tag came from: user (typed in), rule (automatic tagging) or ai.
-- Rule and AI passes only add or remove their own tags; yours are never touched.
ALTER TABLE model_tags ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user';
-- Automatic tagging: fingerprint of the rules and names the rule tags were last
-- worked out from, so a model is re-checked only when one of those changes.
ALTER TABLE models ADD COLUMN IF NOT EXISTS autotag_key TEXT;
-- Semantic search: a local embedding of the model's text, and the fingerprint of
-- the text (and embedding model) it was made from.
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding REAL[];
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding_key TEXT;
-- AI tagging runs, kept so they can be watched, stopped and resumed.
ALTER TABLE models ADD COLUMN IF NOT EXISTS ai_tagged_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS ai_runs (
id SERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'running', -- running | paused | stopped | done | failed
provider TEXT,
model TEXT,
options JSONB NOT NULL DEFAULT '{}', -- { overwriteDescriptions, maxCost, pricesKnown }
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
cost_usd NUMERIC(12, 6) NOT NULL DEFAULT 0,
error TEXT,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS ai_run_items (
run_id INTEGER NOT NULL REFERENCES ai_runs(id) ON DELETE CASCADE,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | error
error TEXT,
tags TEXT[],
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_ai_run_items_status ON ai_run_items(run_id, status);
-- Your own automatic tagging rules: a word or phrase in a name -> a tag.
CREATE TABLE IF NOT EXISTS tag_rules (
id SERIAL PRIMARY KEY,
phrase TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (phrase, tag)
);
-- AI renaming: the name a model had before AI first renamed it (for undo), and
-- what each run renamed.
ALTER TABLE models ADD COLUMN IF NOT EXISTS original_name TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_from TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_to TEXT;
-- Collections the AI suggested for a model, waiting for you to accept or dismiss.
-- Dismissed ones are kept so the same idea isn't suggested for that model again.
CREATE TABLE IF NOT EXISTS collection_suggestions (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | dismissed
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_suggestions_model_name ON collection_suggestions(model_id, lower(name));
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS collections TEXT[];
-- MakerWorld collections watched for new models (lib/collectionWatch.js).
CREATE TABLE IF NOT EXISTS watched_collections (
id SERIAL PRIMARY KEY,
site TEXT NOT NULL DEFAULT 'makerworld',
source_id TEXT NOT NULL, -- the collection's id on the site
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', -- its name there, refreshed on each check
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, -- where new models land
ai_tag BOOLEAN NOT NULL DEFAULT false,
fetch_files BOOLEAN NOT NULL DEFAULT false, -- download files during a check, not just metadata
interval_hours INTEGER NOT NULL DEFAULT 6,
enabled BOOLEAN NOT NULL DEFAULT true,
last_checked_at TIMESTAMPTZ,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_status TEXT, -- ok | error
last_error TEXT,
last_error_reason TEXT, -- makerworld_token: offer the token prompt
last_added INTEGER NOT NULL DEFAULT 0,
listed INTEGER, -- models the page listed at the last check
total INTEGER, -- models the site says the collection holds
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site, source_id)
);
-- What a watch has already seen, so a model isn't imported (or retried) twice.
CREATE TABLE IF NOT EXISTS watched_collection_items (
watch_id INTEGER NOT NULL REFERENCES watched_collections(id) ON DELETE CASCADE,
design_id TEXT NOT NULL,
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, -- imported | partial (no files yet) | existing | error
error TEXT,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (watch_id, design_id)
);
ALTER TABLE watched_collections ADD COLUMN IF NOT EXISTS fetch_files BOOLEAN NOT NULL DEFAULT false;
-- ------------------------------------------------------------------ filament
-- The spools you own. Weights in grams; remaining is what's left on the spool,
-- null when you haven't said. ams_slot is filled in later by AMS sync, and
-- stays null for spools logged by hand.
CREATE TABLE IF NOT EXISTS filament_spools (
id SERIAL PRIMARY KEY,
material TEXT NOT NULL, -- PLA, PETG, ABS, TPU…
colour TEXT NOT NULL DEFAULT '', -- what you call it, e.g. "Bambu Matte Ivory"
colour_hex TEXT, -- #RRGGBB when known, to match a project's colours
brand TEXT NOT NULL DEFAULT '',
total_g INTEGER, -- spool weight when new
remaining_g INTEGER, -- what's left; null = unknown
ams_slot TEXT, -- "<printer>/<unit>/<slot>" once AMS sync lands
code TEXT, -- maker's filament code (Bambu 10101 etc.), what the AMS reports
notes TEXT NOT NULL DEFAULT '',
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
-- What a model asks for, read once from its 3MF project rather than on every
-- page: [{ type, colour }]. A background pass fills these in, the way
-- thumbnails and fingerprints are done, because the reader shells out to Python.
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments JSONB;
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS code TEXT;