All checks were successful
Deploy production / deploy (push) Successful in 1m19s
Owner-only tracker for learning the Cargill Bioindustrial plant in Gouda: schema plant (entries, links with confidence, pending refs), plant.apply_entries bulk import, overview with clusters and flow diagram, kind tabs, glossary, questions, detail pages and owner inline editing. Docs in docs/plant-tracker.md.
1142 lines
41 KiB
PL/PgSQL
1142 lines
41 KiB
PL/PgSQL
CREATE SCHEMA "plant";
|
|
--> statement-breakpoint
|
|
CREATE TABLE "plant"."entries" (
|
|
"id" serial PRIMARY KEY,
|
|
"kind" text NOT NULL,
|
|
"slug" text NOT NULL,
|
|
"title" text NOT NULL,
|
|
"summary" text,
|
|
"body" text,
|
|
"parent_id" integer,
|
|
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
"source" text,
|
|
"confidence" text DEFAULT 'assumed' NOT NULL,
|
|
"answered_at" timestamp with time zone,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "entries_kind_check" CHECK ("kind" IN ('process_step','product','department','equipment','glossary','question','note','person_role')),
|
|
CONSTRAINT "entries_confidence_check" CHECK ("confidence" IN ('confirmed','assumed','open')),
|
|
CONSTRAINT "entries_slug_check" CHECK ("slug" ~ '^[a-z0-9]+(-[a-z0-9]+)*$')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "plant"."links" (
|
|
"id" serial PRIMARY KEY,
|
|
"from_id" integer NOT NULL,
|
|
"to_id" integer NOT NULL,
|
|
"relation" text NOT NULL,
|
|
"label" text,
|
|
"dashed" boolean DEFAULT false NOT NULL,
|
|
"confidence" text DEFAULT 'confirmed' NOT NULL,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "links_confidence_check" CHECK ("confidence" IN ('confirmed','assumed','open')),
|
|
CONSTRAINT "links_relation_check" CHECK ("relation" IN ('feeds_into','made_in','uses','part_of','related')),
|
|
CONSTRAINT "links_not_self_check" CHECK ("from_id" <> "to_id")
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "plant"."pending" (
|
|
"id" serial PRIMARY KEY,
|
|
"from_id" integer NOT NULL,
|
|
"field" text NOT NULL,
|
|
"to_ref" text NOT NULL,
|
|
"label" text,
|
|
"dashed" boolean DEFAULT false NOT NULL,
|
|
"confidence" text DEFAULT 'confirmed' NOT NULL,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "pending_field_check" CHECK ("field" IN ('parent','feeds_into','made_in','uses','part_of','related'))
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE UNIQUE INDEX "entries_kind_slug_key" ON "plant"."entries" ("kind","slug");--> statement-breakpoint
|
|
CREATE INDEX "entries_parent_idx" ON "plant"."entries" ("parent_id");--> statement-breakpoint
|
|
CREATE UNIQUE INDEX "links_from_to_relation_key" ON "plant"."links" ("from_id","to_id","relation");--> statement-breakpoint
|
|
CREATE INDEX "links_to_idx" ON "plant"."links" ("to_id");--> statement-breakpoint
|
|
CREATE UNIQUE INDEX "pending_from_field_ref_key" ON "plant"."pending" ("from_id","field","to_ref");--> statement-breakpoint
|
|
ALTER TABLE "plant"."entries" ADD CONSTRAINT "entries_parent_id_entries_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "plant"."entries"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint
|
|
ALTER TABLE "plant"."links" ADD CONSTRAINT "links_from_id_entries_id_fkey" FOREIGN KEY ("from_id") REFERENCES "plant"."entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
|
|
ALTER TABLE "plant"."links" ADD CONSTRAINT "links_to_id_entries_id_fkey" FOREIGN KEY ("to_id") REFERENCES "plant"."entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
|
|
ALTER TABLE "plant"."pending" ADD CONSTRAINT "pending_from_id_entries_id_fkey" FOREIGN KEY ("from_id") REFERENCES "plant"."entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
|
|
-- Keep updated_at honest for raw SQL updates too (the app and bots use plain SQL).
|
|
CREATE FUNCTION "plant"."touch"() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
NEW.updated_at := now();
|
|
RETURN NEW;
|
|
END $$;
|
|
--> statement-breakpoint
|
|
CREATE TRIGGER "entries_touch" BEFORE UPDATE ON "plant"."entries"
|
|
FOR EACH ROW EXECUTE FUNCTION "plant"."touch"();
|
|
--> statement-breakpoint
|
|
-- Same rules as slugify() in #lib/plant: lowercase ASCII words joined by "-".
|
|
CREATE FUNCTION "plant"."slugify"(t text) RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
SELECT left(btrim(regexp_replace(
|
|
translate(lower(coalesce(t, '')), 'àáâäãåèéêëìíîïòóôöõùúûüýÿçñ', 'aaaaaaeeeeiiiiooooouuuuyycn'),
|
|
'[^a-z0-9]+', '-', 'g'), '-'), 80)
|
|
$$;
|
|
--> statement-breakpoint
|
|
-- An entry reference: "kind:slug" (kind may use "-" or "_"), {"kind": ..., "slug": ...}, or a
|
|
-- bare "slug". A bare slug used by several kinds resolves by kind priority: process_step, product,
|
|
-- department, equipment, note, question, person_role, glossary (so "distillation" is the process
|
|
-- step, not the glossary word). Unknown references raise.
|
|
CREATE FUNCTION "plant"."resolve"(ref jsonb) RETURNS integer LANGUAGE plpgsql STABLE AS $$
|
|
DECLARE
|
|
t text;
|
|
k text;
|
|
s text;
|
|
ids integer[];
|
|
BEGIN
|
|
IF ref IS NULL OR jsonb_typeof(ref) = 'null' THEN
|
|
RETURN NULL;
|
|
ELSIF jsonb_typeof(ref) = 'object' THEN
|
|
k := ref->>'kind';
|
|
s := ref->>'slug';
|
|
ELSE
|
|
t := btrim(ref #>> '{}');
|
|
IF position(':' IN t) > 0 THEN
|
|
k := split_part(t, ':', 1);
|
|
s := substr(t, position(':' IN t) + 1);
|
|
ELSE
|
|
s := t;
|
|
END IF;
|
|
END IF;
|
|
k := nullif(replace(lower(btrim(k)), '-', '_'), '');
|
|
s := lower(btrim(s));
|
|
SELECT array_agg(id ORDER BY array_position(ARRAY['process_step', 'product', 'department',
|
|
'equipment', 'note', 'question', 'person_role', 'glossary'], kind)) INTO ids
|
|
FROM plant.entries WHERE slug = s AND (k IS NULL OR kind = k);
|
|
IF ids IS NULL THEN
|
|
RAISE EXCEPTION 'plant: unknown entry %', ref::text;
|
|
END IF;
|
|
RETURN ids[1];
|
|
END $$;
|
|
--> statement-breakpoint
|
|
-- Like resolve, but NULL for an unknown reference (apply_entries reports those instead of failing).
|
|
CREATE FUNCTION "plant"."try_resolve"(ref jsonb) RETURNS integer LANGUAGE plpgsql STABLE AS $$
|
|
BEGIN
|
|
RETURN plant.resolve(ref);
|
|
EXCEPTION WHEN raise_exception THEN
|
|
RETURN NULL;
|
|
END $$;
|
|
--> statement-breakpoint
|
|
-- One link from an entry: {"to": ref, "relation": "feeds_into", "label": "...", "dashed": true,
|
|
-- "confidence": "assumed"} (relation defaults to "related", confidence to "confirmed"); {"to": ref, "relation": ..., "remove": true} deletes it.
|
|
-- Returns 1 when linked, -1 when removed, 0 when there was nothing to remove.
|
|
CREATE FUNCTION "plant"."apply_link"(src_id integer, l jsonb) RETURNS integer LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
dst_id integer := plant.resolve(l->'to');
|
|
rel text := coalesce(nullif(btrim(l->>'relation'), ''), 'related');
|
|
n integer;
|
|
BEGIN
|
|
IF src_id IS NULL OR dst_id IS NULL THEN
|
|
RAISE EXCEPTION 'plant: a link needs both ends: %', l::text;
|
|
END IF;
|
|
IF coalesce((l->>'remove')::boolean, false) THEN
|
|
DELETE FROM plant.links k WHERE k.from_id = src_id AND k.to_id = dst_id AND k.relation = rel;
|
|
GET DIAGNOSTICS n = ROW_COUNT;
|
|
RETURN -n;
|
|
END IF;
|
|
INSERT INTO plant.links AS k (from_id, to_id, relation, label, dashed, confidence)
|
|
VALUES (src_id, dst_id, rel, nullif(btrim(l->>'label'), ''), coalesce((l->>'dashed')::boolean, false),
|
|
coalesce(nullif(btrim(l->>'confidence'), ''), 'confirmed'))
|
|
ON CONFLICT (from_id, to_id, relation) DO UPDATE SET
|
|
label = CASE WHEN l ? 'label' THEN nullif(btrim(l->>'label'), '') ELSE k.label END,
|
|
dashed = coalesce((l->>'dashed')::boolean, k.dashed),
|
|
confidence = coalesce(nullif(btrim(l->>'confidence'), ''), k.confidence);
|
|
RETURN 1;
|
|
END $$;
|
|
--> statement-breakpoint
|
|
-- Bulk upsert, the format bots (Walter White) send. Upserts entries by (kind, slug), then sets
|
|
-- parents and links by reference, then deletes. Fields left out keep their current value; new
|
|
-- entries need a title. A top-level array is treated as {"entries": [...]}.
|
|
-- {"source": "Walter White", -- default source for new entries
|
|
-- "entries": [{
|
|
-- "kind": "department", -- process_step | product | department |
|
|
-- -- equipment | glossary | question | note | person_role
|
|
-- "slug": "kolommen", -- optional, defaults to slugify(title)
|
|
-- "title": "Kolommen", "summary": "one line", "body": "markdown",
|
|
-- "tags": ["dutch"], "sort_order": 10, "source": "Stan",
|
|
-- "confidence": "confirmed" | "assumed" | "open",
|
|
-- "answered": true, -- questions: mark answered (false reopens)
|
|
-- "parent": "process_step:distillation", -- or null to clear
|
|
-- "replace_links": false, -- true: drop this entry's outgoing links first
|
|
-- "body_mode": "append", -- add the body as a new paragraph (once)
|
|
-- "links": [{"to": "product:fr3", "relation": "made_in" | "feeds_into" | "uses" | "part_of" | "related",
|
|
-- "label": "short edge label", "dashed": false, "confidence": "assumed",
|
|
-- "remove": false}]}],
|
|
-- "links": [{"from": "product:fr3", "to": "department:fr3", "relation": "made_in"}],
|
|
-- "delete": ["note:old-note"]}
|
|
-- References are "kind:slug", {"kind","slug"} or a bare slug (see plant.resolve for ties).
|
|
-- Parents and links whose target doesn't exist yet are listed in "unresolved" and kept in
|
|
-- plant.pending; every run retries them, so a later batch that adds the target resolves them. The
|
|
-- rest of the batch still applies. "parent_slug" is accepted as an alias of "parent".
|
|
-- On update, an empty summary or body keeps the current text, and tags are merged, unless the
|
|
-- entry sets "confidence": "confirmed" or "replace_tags": true (then the list replaces them).
|
|
-- A question tagged "answered" is marked answered.
|
|
-- Returns {"inserted", "updated", "linked", "unlinked", "deleted", "unresolved": [{from, field, to}],
|
|
-- "resolved_pending", "pending", "entries": [{id, kind, slug, action}]}.
|
|
CREATE FUNCTION "plant"."apply_entries"(p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
doc jsonb := CASE WHEN jsonb_typeof(p) = 'array' THEN jsonb_build_object('entries', p) ELSE p END;
|
|
src text := nullif(btrim(doc->>'source'), '');
|
|
e jsonb;
|
|
l jsonb;
|
|
r jsonb;
|
|
k text;
|
|
s text;
|
|
eid integer;
|
|
rc integer;
|
|
n_ins integer := 0;
|
|
n_upd integer := 0;
|
|
n_link integer := 0;
|
|
n_unlink integer := 0;
|
|
n_del integer := 0;
|
|
n_resolved integer := 0;
|
|
res jsonb := '[]'::jsonb;
|
|
refs jsonb := '[]'::jsonb;
|
|
bad jsonb := '[]'::jsonb;
|
|
pref jsonb;
|
|
dst integer;
|
|
BEGIN
|
|
IF jsonb_typeof(doc) <> 'object' THEN
|
|
RAISE EXCEPTION 'plant.apply_entries: expected an object or an array';
|
|
END IF;
|
|
-- Pass 1: entries.
|
|
FOR e IN SELECT * FROM jsonb_array_elements(coalesce(doc->'entries', '[]'::jsonb)) LOOP
|
|
k := replace(lower(btrim(e->>'kind')), '-', '_');
|
|
IF coalesce(k, '') = '' THEN
|
|
RAISE EXCEPTION 'plant.apply_entries: entry without a kind: %', e::text;
|
|
END IF;
|
|
s := coalesce(nullif(lower(btrim(e->>'slug')), ''), plant.slugify(e->>'title'));
|
|
IF coalesce(s, '') = '' THEN
|
|
RAISE EXCEPTION 'plant.apply_entries: entry needs a slug or a title: %', e::text;
|
|
END IF;
|
|
SELECT id INTO eid FROM plant.entries WHERE kind = k AND slug = s;
|
|
IF eid IS NULL THEN
|
|
IF nullif(btrim(e->>'title'), '') IS NULL THEN
|
|
RAISE EXCEPTION 'plant.apply_entries: new entry %:% needs a title', k, s;
|
|
END IF;
|
|
INSERT INTO plant.entries (kind, slug, title, summary, body, tags, sort_order, source, confidence, answered_at)
|
|
VALUES (
|
|
k, s, btrim(e->>'title'), nullif(btrim(e->>'summary'), ''), nullif(btrim(e->>'body'), ''),
|
|
coalesce(ARRAY(SELECT btrim(x) FROM jsonb_array_elements_text(e->'tags') x WHERE btrim(x) <> ''), '{}'),
|
|
coalesce((e->>'sort_order')::int,
|
|
(SELECT coalesce(max(sort_order), 0) + 10 FROM plant.entries WHERE kind = k)),
|
|
coalesce(nullif(btrim(e->>'source'), ''), src),
|
|
coalesce(nullif(e->>'confidence', ''), CASE WHEN k = 'question' THEN 'open' ELSE 'assumed' END),
|
|
CASE WHEN coalesce((e->>'answered')::boolean, false) THEN now() END)
|
|
RETURNING id INTO eid;
|
|
n_ins := n_ins + 1;
|
|
res := res || jsonb_build_array(jsonb_build_object('id', eid, 'kind', k, 'slug', s, 'action', 'inserted'));
|
|
ELSE
|
|
UPDATE plant.entries SET
|
|
title = coalesce(nullif(btrim(e->>'title'), ''), title),
|
|
summary = coalesce(nullif(btrim(e->>'summary'), ''), summary),
|
|
body = CASE
|
|
WHEN nullif(btrim(e->>'body'), '') IS NULL THEN body
|
|
WHEN e->>'body_mode' = 'append' AND body IS NOT NULL
|
|
AND position(btrim(e->>'body') IN body) = 0 THEN body || E'\n\n' || btrim(e->>'body')
|
|
WHEN e->>'body_mode' = 'append' THEN coalesce(body, btrim(e->>'body'))
|
|
ELSE btrim(e->>'body') END,
|
|
tags = CASE
|
|
WHEN NOT e ? 'tags' THEN tags
|
|
WHEN coalesce((e->>'replace_tags')::boolean, e->>'confidence' = 'confirmed')
|
|
THEN coalesce(ARRAY(SELECT btrim(x) FROM jsonb_array_elements_text(e->'tags') x WHERE btrim(x) <> ''), '{}')
|
|
ELSE tags || coalesce(ARRAY(SELECT DISTINCT btrim(x) FROM jsonb_array_elements_text(e->'tags') x
|
|
WHERE btrim(x) <> '' AND NOT btrim(x) = ANY(tags)), '{}') END,
|
|
sort_order = coalesce((e->>'sort_order')::int, sort_order),
|
|
source = CASE WHEN e ? 'source' THEN nullif(btrim(e->>'source'), '') ELSE source END,
|
|
confidence = coalesce(nullif(e->>'confidence', ''), confidence),
|
|
answered_at = CASE WHEN e ? 'answered'
|
|
THEN CASE WHEN (e->>'answered')::boolean THEN coalesce(answered_at, now()) END
|
|
ELSE answered_at END
|
|
WHERE id = eid;
|
|
n_upd := n_upd + 1;
|
|
res := res || jsonb_build_array(jsonb_build_object('id', eid, 'kind', k, 'slug', s, 'action', 'updated'));
|
|
END IF;
|
|
-- Questions tagged "answered" are marked answered (the tag itself is dropped).
|
|
IF k = 'question' AND e->'tags' ? 'answered' THEN
|
|
UPDATE plant.entries SET answered_at = coalesce(answered_at, now()),
|
|
tags = array_remove(tags, 'answered') WHERE id = eid;
|
|
END IF;
|
|
refs := refs || jsonb_build_array(jsonb_build_object('id', eid, 'e', e));
|
|
END LOOP;
|
|
-- Pass 2: parents and per-entry links (every entry of this batch exists now).
|
|
FOR r IN SELECT * FROM jsonb_array_elements(refs) LOOP
|
|
eid := (r->>'id')::int;
|
|
e := r->'e';
|
|
IF e ? 'parent' OR e ? 'parent_slug' THEN
|
|
pref := CASE WHEN e ? 'parent' THEN e->'parent' ELSE e->'parent_slug' END;
|
|
IF coalesce(pref #>> '{}', '') = '' THEN
|
|
UPDATE plant.entries SET parent_id = NULL WHERE id = eid;
|
|
ELSE
|
|
dst := plant.try_resolve(pref);
|
|
DELETE FROM plant.pending WHERE from_id = eid AND field = 'parent';
|
|
IF dst IS NULL THEN
|
|
bad := bad || jsonb_build_array(jsonb_build_object(
|
|
'from', (SELECT kind || ':' || slug FROM plant.entries WHERE id = eid), 'field', 'parent', 'to', pref));
|
|
INSERT INTO plant.pending (from_id, field, to_ref) VALUES (eid, 'parent', pref #>> '{}');
|
|
ELSE
|
|
UPDATE plant.entries SET parent_id = dst WHERE id = eid AND dst <> eid;
|
|
END IF;
|
|
END IF;
|
|
END IF;
|
|
IF coalesce((e->>'replace_links')::boolean, false) THEN
|
|
DELETE FROM plant.links WHERE from_id = eid;
|
|
GET DIAGNOSTICS rc = ROW_COUNT;
|
|
n_unlink := n_unlink + rc;
|
|
END IF;
|
|
FOR l IN SELECT * FROM jsonb_array_elements(coalesce(e->'links', '[]'::jsonb)) LOOP
|
|
IF plant.try_resolve(l->'to') IS NULL THEN
|
|
bad := bad || jsonb_build_array(jsonb_build_object(
|
|
'from', (SELECT kind || ':' || slug FROM plant.entries WHERE id = eid),
|
|
'field', coalesce(nullif(l->>'relation', ''), 'related'), 'to', l->'to'));
|
|
IF NOT coalesce((l->>'remove')::boolean, false) THEN
|
|
INSERT INTO plant.pending AS q (from_id, field, to_ref, label, dashed, confidence)
|
|
VALUES (eid, coalesce(nullif(btrim(l->>'relation'), ''), 'related'), l->'to' #>> '{}',
|
|
nullif(btrim(l->>'label'), ''), coalesce((l->>'dashed')::boolean, false),
|
|
coalesce(nullif(btrim(l->>'confidence'), ''), 'confirmed'))
|
|
ON CONFLICT (from_id, field, to_ref) DO UPDATE SET label = excluded.label, dashed = excluded.dashed,
|
|
confidence = excluded.confidence;
|
|
END IF;
|
|
CONTINUE;
|
|
END IF;
|
|
rc := plant.apply_link(eid, l);
|
|
IF rc > 0 THEN n_link := n_link + rc; ELSE n_unlink := n_unlink - rc; END IF;
|
|
END LOOP;
|
|
END LOOP;
|
|
-- Top-level links: {"from": ref, "to": ref, ...}.
|
|
FOR l IN SELECT * FROM jsonb_array_elements(coalesce(doc->'links', '[]'::jsonb)) LOOP
|
|
IF plant.try_resolve(l->'from') IS NULL OR plant.try_resolve(l->'to') IS NULL THEN
|
|
bad := bad || jsonb_build_array(jsonb_build_object('from', l->'from',
|
|
'field', coalesce(nullif(l->>'relation', ''), 'related'), 'to', l->'to'));
|
|
CONTINUE;
|
|
END IF;
|
|
rc := plant.apply_link(plant.resolve(l->'from'), l);
|
|
IF rc > 0 THEN n_link := n_link + rc; ELSE n_unlink := n_unlink - rc; END IF;
|
|
END LOOP;
|
|
-- Deletes (links go with the entry; children lose their parent).
|
|
FOR r IN SELECT * FROM jsonb_array_elements(coalesce(doc->'delete', '[]'::jsonb)) LOOP
|
|
DELETE FROM plant.entries WHERE id = plant.resolve(r);
|
|
n_del := n_del + 1;
|
|
END LOOP;
|
|
-- Retry pending parents and links: targets added by this (or any later) batch resolve them.
|
|
FOR r IN SELECT to_jsonb(q) FROM plant.pending q ORDER BY id LOOP
|
|
dst := plant.try_resolve(to_jsonb(r->>'to_ref'));
|
|
CONTINUE WHEN dst IS NULL OR dst = (r->>'from_id')::int;
|
|
IF r->>'field' = 'parent' THEN
|
|
UPDATE plant.entries SET parent_id = dst WHERE id = (r->>'from_id')::int;
|
|
ELSE
|
|
PERFORM plant.apply_link((r->>'from_id')::int, jsonb_build_object('to', r->'to_ref',
|
|
'relation', r->'field', 'label', r->'label', 'dashed', r->'dashed', 'confidence', r->'confidence'));
|
|
n_link := n_link + 1;
|
|
END IF;
|
|
DELETE FROM plant.pending WHERE id = (r->>'id')::int;
|
|
n_resolved := n_resolved + 1;
|
|
END LOOP;
|
|
RETURN jsonb_build_object('inserted', n_ins, 'updated', n_upd, 'linked', n_link,
|
|
'unlinked', n_unlink, 'deleted', n_del, 'unresolved', bad, 'resolved_pending', n_resolved,
|
|
'pending', (SELECT count(*) FROM plant.pending), 'entries', res);
|
|
END $$;
|
|
--> statement-breakpoint
|
|
-- Seed: Walter White's guide "What the Cargill Bioindustrial factory makes" (Page, 2026-10-26).
|
|
-- Only what the guide says; confidence: glossary words confirmed, plant specifics assumed,
|
|
-- department mapping and the four questions open.
|
|
SELECT plant.apply_entries($seed$
|
|
{
|
|
"source": "Walter White's factory guide (2026-10-26)",
|
|
"entries": [
|
|
{
|
|
"kind": "note",
|
|
"slug": "factory-overview",
|
|
"title": "The factory in one picture",
|
|
"summary": "Plant oil comes in, gets cleaned, and is either split into parts or used whole.",
|
|
"body": "Plant oil comes in, gets cleaned, and is either split into parts or used whole. The parts get sorted, glued together, or attached to new pieces, then shipped as liquids or pellets.\n\nAll of it can also be made from crude oil; making it from plants is what puts the \"bio\" in bioindustrial.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"overview"
|
|
],
|
|
"sort_order": 10
|
|
},
|
|
{
|
|
"kind": "note",
|
|
"slug": "atoms-and-molecules",
|
|
"title": "What everything is made of",
|
|
"summary": "Atoms hook together into molecules, like Lego bricks into builds.",
|
|
"body": "Everything is made of atoms, tiny balls of a few types. Atoms hook together into molecules, like Lego bricks into builds. A chemical reaction is a function: molecules go in, rewired molecules come out, and no atoms appear or disappear.\n\n| Atom | Role at the factory |\n| --- | --- |\n| C, carbon | The backbone builder; links into long chains |\n| H, hydrogen | Small, sticks onto everything |\n| O, oxygen | Gives molecules reactive handles to grab |\n| N, nitrogen | Shows up in some glues and plastics |",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"basics"
|
|
],
|
|
"sort_order": 20
|
|
},
|
|
{
|
|
"kind": "note",
|
|
"slug": "customers",
|
|
"title": "Customers",
|
|
"summary": "Glue, plastics, lubricants, FR3.",
|
|
"body": "Where the plant's liquids and pellets end up: glue, plastics, lubricants and FR3.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"outlet"
|
|
],
|
|
"sort_order": 30
|
|
},
|
|
{
|
|
"kind": "process_step",
|
|
"slug": "cleaning",
|
|
"title": "Cleaning",
|
|
"summary": "Bleach and filter the incoming oil.",
|
|
"body": "Plant oil comes in and gets cleaned first: bleached and filtered.\n\nSee **Bleaching**: special clay soaks up colour, smell and impurities, then gets filtered out.",
|
|
"confidence": "assumed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:splitting",
|
|
"relation": "feeds_into"
|
|
},
|
|
{
|
|
"to": "process_step:esterification",
|
|
"relation": "feeds_into",
|
|
"label": "used whole",
|
|
"dashed": true
|
|
}
|
|
],
|
|
"sort_order": 10
|
|
},
|
|
{
|
|
"kind": "process_step",
|
|
"slug": "splitting",
|
|
"title": "Splitting",
|
|
"summary": "Hot water cuts the teeth off.",
|
|
"body": "**Move 1: snap the teeth off.** Hot water at high pressure cuts the teeth (fatty acids) off the handle (glycerol). Out come free fatty acids and glycerol, which is sold on its own for cosmetics, food and pharma.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"the four moves"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "product:glycerol",
|
|
"relation": "feeds_into"
|
|
},
|
|
{
|
|
"to": "process_step:distillation",
|
|
"relation": "feeds_into",
|
|
"label": "fatty acids"
|
|
},
|
|
{
|
|
"to": "equipment:reactors",
|
|
"relation": "uses"
|
|
}
|
|
],
|
|
"sort_order": 20
|
|
},
|
|
{
|
|
"kind": "process_step",
|
|
"slug": "distillation",
|
|
"title": "Distillation columns",
|
|
"summary": "Sort by boiling point (the kolommen).",
|
|
"body": "**Move 2: sort the pieces.** A tall tower, hot at the bottom and cooler at the top. The mix boils, vapor rises, and each molecule turns back to liquid at the height that matches its boiling point. Tap the tower at different heights to collect different products.\n\n### Why columns run under vacuum\n\nA liquid boils when its vapor pressure matches the pressure around it. Lower the surrounding pressure and it boils at a lower temperature, the same reason water boils cooler on a mountain. Fatty acids darken and break down if cooked too hot, so the columns pull a vacuum to sort them gently. Each tray inside is like one pass of a sorting algorithm; thirty stacked trays give a very clean split.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"the four moves",
|
|
"kolommen"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "process_step:dimerization",
|
|
"relation": "feeds_into"
|
|
},
|
|
{
|
|
"to": "process_step:esterification",
|
|
"relation": "feeds_into"
|
|
},
|
|
{
|
|
"to": "process_step:pellet-belt",
|
|
"relation": "feeds_into"
|
|
},
|
|
{
|
|
"to": "department:kolommen",
|
|
"relation": "part_of"
|
|
}
|
|
],
|
|
"sort_order": 30
|
|
},
|
|
{
|
|
"kind": "process_step",
|
|
"slug": "dimerization",
|
|
"title": "Dimerization",
|
|
"summary": "Glue two chains together (the dimeren).",
|
|
"body": "**Move 4: glue two teeth together.** With heat and a clay catalyst, two chains link at their weak spots into one big molecule with an acid end on both sides. After dimerization the mix goes back through the columns to separate singles, doubles and triples.\n\n### Why two-handed molecules matter\n\nA dimer acid has an acid end on both sides, so it can grab other two-handed molecules and form long chains called polymers. That is how dimer acids end up in hot-melt glue (glue guns, packaging, shoes, book bindings), polyamide resins for inks and coatings, and lubricant additives. Their long greasy chains make them flexible and water-resistant.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"the four moves",
|
|
"dimeren"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "process_step:distillation",
|
|
"relation": "feeds_into",
|
|
"label": "sort again",
|
|
"dashed": true
|
|
},
|
|
{
|
|
"to": "equipment:reactors",
|
|
"relation": "uses"
|
|
},
|
|
{
|
|
"to": "department:dimeren",
|
|
"relation": "part_of"
|
|
}
|
|
],
|
|
"sort_order": 40
|
|
},
|
|
{
|
|
"kind": "process_step",
|
|
"slug": "esterification",
|
|
"title": "Esterification",
|
|
"summary": "Attach a new handle (the esters).",
|
|
"body": "**Move 3: attach a new handle.** An acid end and an alcohol join and give off one water molecule. Choosing a different alcohol changes how the product flows in the cold, how long it lasts, and how easily it burns.\n\n### Why esters need the water removed\n\nReactions run both ways: the water can cut the new link again. Removing water as it forms keeps the reaction moving forward, like draining a queue so the producer keeps producing. The original oil is itself an ester, so esterification is splitting in reverse, with the option to pick a different handle.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"the four moves",
|
|
"esters"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "note:customers",
|
|
"relation": "feeds_into"
|
|
},
|
|
{
|
|
"to": "equipment:reactors",
|
|
"relation": "uses"
|
|
},
|
|
{
|
|
"to": "department:esters",
|
|
"relation": "part_of"
|
|
}
|
|
],
|
|
"sort_order": 50
|
|
},
|
|
{
|
|
"kind": "process_step",
|
|
"slug": "pellet-belt",
|
|
"title": "Pellet belt",
|
|
"summary": "Solid products to pellets (the pillenband).",
|
|
"body": "This is most likely a **pastillator**. Hot liquid product drips from a row of nozzles onto a long steel belt, cold water sprays the belt's underside, and each drop freezes into a small round pellet before it's scraped off and bagged.\n\nSmall drops have lots of surface for their volume, so they set in seconds, the same reason fries cook faster than whole potatoes. Pellets are easy for customers to pour, weigh and remelt.\n\n> Worth confirming at work: if liquid drips onto a belt and pellets come off the end, that's it.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"pillenband"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "note:customers",
|
|
"relation": "feeds_into"
|
|
}
|
|
],
|
|
"sort_order": 60
|
|
},
|
|
{
|
|
"kind": "product",
|
|
"slug": "plant-oil",
|
|
"title": "Plant oil",
|
|
"summary": "The raw material: soy, rapeseed, palm…",
|
|
"body": "**Picture a comb.** One oil molecule is a short handle with three long teeth hanging off it. The handle is glycerol. Each tooth is a fatty acid: a chain of about 16 to 22 carbon atoms with an acid end that hooks onto the handle. The big word for the whole comb is triglyceride, which just means three teeth on a glycerol handle.\n\n- **Length:** longer teeth are heavier and stickier, so they melt and boil at higher temperatures.\n- **Straight teeth (saturated):** stack neatly like pencils in a box, so the fat is solid at room temperature, like butter.\n- **Kinked teeth (unsaturated):** a double bond puts a bend in the chain, so they can't stack and stay liquid, like sunflower oil.\n\nThose double bonds are weak spots, and weak spots are where the factory does its chemistry. They are the attachment points for building new things.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"raw material"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "process_step:cleaning",
|
|
"relation": "feeds_into"
|
|
}
|
|
],
|
|
"sort_order": 10
|
|
},
|
|
{
|
|
"kind": "product",
|
|
"slug": "glycerol",
|
|
"title": "Glycerol",
|
|
"summary": "The loose handle, sold on its own.",
|
|
"body": "The handle that splitting cuts loose: a sweet syrupy liquid, sold on its own for cosmetics, food and pharma.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"by-product"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "process_step:splitting",
|
|
"relation": "made_in"
|
|
}
|
|
],
|
|
"sort_order": 20
|
|
},
|
|
{
|
|
"kind": "product",
|
|
"slug": "fatty-acids",
|
|
"title": "Free fatty acids",
|
|
"summary": "The teeth cut off in splitting, then sorted in the columns.",
|
|
"body": "What splitting produces besides glycerol: the fatty acids (teeth), which go to the distillation columns to be sorted.",
|
|
"confidence": "assumed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:splitting",
|
|
"relation": "made_in"
|
|
}
|
|
],
|
|
"sort_order": 30
|
|
},
|
|
{
|
|
"kind": "product",
|
|
"slug": "esters",
|
|
"title": "Esters",
|
|
"summary": "Acid plus alcohol, joined, minus one water.",
|
|
"body": "Made by esterification. The alcohol chosen as the new handle changes how the product flows in the cold, how long it lasts, and how easily it burns.",
|
|
"confidence": "assumed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:esterification",
|
|
"relation": "made_in"
|
|
}
|
|
],
|
|
"sort_order": 40
|
|
},
|
|
{
|
|
"kind": "product",
|
|
"slug": "dimer-acids",
|
|
"title": "Dimer acids",
|
|
"summary": "Two chains joined, with an acid end on both sides.",
|
|
"body": "Made by dimerization. Because they have an acid end on both sides they can form polymers, so they end up in:\n\n- hot-melt glue (glue guns, packaging, shoes, book bindings)\n- polyamide resins for inks and coatings\n- lubricant additives\n\nTheir long greasy chains make them flexible and water-resistant.",
|
|
"confidence": "assumed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:dimerization",
|
|
"relation": "made_in"
|
|
}
|
|
],
|
|
"sort_order": 50
|
|
},
|
|
{
|
|
"kind": "product",
|
|
"slug": "fr3",
|
|
"title": "FR3",
|
|
"summary": "Natural ester fluid from soybean oil for transformers.",
|
|
"body": "FR3 is a natural ester fluid made from soybean oil. It fills electrical transformers, where the liquid has two jobs: insulate so high voltage can't arc through it, and carry heat away from the copper coils.\n\n### Why not mineral oil\n\n- **Much harder to ignite:** a far higher fire point, so faults rarely become fires.\n- **Biodegradable:** a leak is a much smaller problem for soil and water.\n- **Handles moisture:** helps the paper insulation in transformers last longer.\n\n### Why datacenters want it\n\nIn immersion cooling, servers sit dunked in a liquid that doesn't conduct electricity. Liquids carry heat far better than air, so more compute fits in less space with less fan and AC energy.",
|
|
"confidence": "assumed",
|
|
"tags": [
|
|
"transformers",
|
|
"datacenters"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "product:esters",
|
|
"relation": "related",
|
|
"label": "a natural ester"
|
|
},
|
|
{
|
|
"to": "note:customers",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 60
|
|
},
|
|
{
|
|
"kind": "department",
|
|
"slug": "kolommen",
|
|
"title": "Kolommen",
|
|
"summary": "Name the guide uses for distillation columns. Which moves this department covers is still to confirm.",
|
|
"confidence": "open",
|
|
"links": [
|
|
{
|
|
"to": "question:which-department",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 10
|
|
},
|
|
{
|
|
"kind": "department",
|
|
"slug": "dimeren",
|
|
"title": "Dimeren",
|
|
"summary": "Name the guide uses for dimerization. Which moves this department covers is still to confirm.",
|
|
"confidence": "open",
|
|
"links": [
|
|
{
|
|
"to": "question:which-department",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 20
|
|
},
|
|
{
|
|
"kind": "department",
|
|
"slug": "esters",
|
|
"title": "Esters",
|
|
"summary": "Name the guide uses for esterification. Which moves this department covers is still to confirm.",
|
|
"confidence": "open",
|
|
"links": [
|
|
{
|
|
"to": "question:which-department",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 30
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "reactors",
|
|
"title": "Reactors",
|
|
"summary": "Steel tanks with mixers, heating jackets and pressure ratings where splitting, esters and dimers are made",
|
|
"confidence": "assumed",
|
|
"sort_order": 10
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "heat-exchangers",
|
|
"title": "Heat exchangers",
|
|
"summary": "Pipes in pipes or plate stacks that pass heat between streams without mixing them",
|
|
"confidence": "assumed",
|
|
"sort_order": 20
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "steam-and-cooling-water",
|
|
"title": "Steam and cooling water",
|
|
"summary": "The plant's main hot and cold sources; most of the energy bill is moving heat",
|
|
"confidence": "assumed",
|
|
"sort_order": 30
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "hydrogenation",
|
|
"title": "Hydrogenation",
|
|
"summary": "Adding hydrogen to straighten kinked chains, making oils harder and more stable",
|
|
"confidence": "assumed",
|
|
"links": [
|
|
{
|
|
"to": "glossary:double-bond",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 40
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "bleaching",
|
|
"title": "Bleaching",
|
|
"summary": "Special clay soaks up color, smell and impurities, then gets filtered out",
|
|
"confidence": "assumed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:cleaning",
|
|
"relation": "part_of"
|
|
}
|
|
],
|
|
"sort_order": 50
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "tank-farm",
|
|
"title": "Tank farm",
|
|
"summary": "Storage for raw oil and products, often heated so waxy stuff stays liquid",
|
|
"confidence": "assumed",
|
|
"sort_order": 60
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "lab-qc",
|
|
"title": "Lab (QC)",
|
|
"summary": "Tests acid value, color, viscosity, water content and purity",
|
|
"confidence": "assumed",
|
|
"links": [
|
|
{
|
|
"to": "glossary:acid-value",
|
|
"relation": "related"
|
|
},
|
|
{
|
|
"to": "glossary:viscosity",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 70
|
|
},
|
|
{
|
|
"kind": "equipment",
|
|
"slug": "control-room",
|
|
"title": "Control room",
|
|
"summary": "Sensors, setpoints and feedback loops running valves and pumps",
|
|
"confidence": "assumed",
|
|
"sort_order": 80
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "molecule",
|
|
"title": "Molecule",
|
|
"summary": "Atoms linked together",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"basics"
|
|
],
|
|
"sort_order": 10
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "triglyceride",
|
|
"title": "Triglyceride",
|
|
"summary": "An oil molecule: a handle with three teeth",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "product:plant-oil",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 20
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "glycerol",
|
|
"title": "Glycerol",
|
|
"summary": "The handle; a sweet syrupy liquid",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "product:glycerol",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 30
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "fatty-acid",
|
|
"title": "Fatty acid",
|
|
"summary": "One tooth: a long carbon chain with an acid end",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "product:fatty-acids",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 40
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "double-bond",
|
|
"title": "Double bond",
|
|
"summary": "A kink and a weak spot in a chain",
|
|
"confidence": "confirmed",
|
|
"sort_order": 50
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "ester",
|
|
"title": "Ester",
|
|
"summary": "Acid plus alcohol, joined, minus one water",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:esterification",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 60
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "dimer",
|
|
"title": "Dimer",
|
|
"summary": "Two pieces joined into one",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:dimerization",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 70
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "polymer",
|
|
"title": "Polymer",
|
|
"summary": "Many pieces joined into a long chain",
|
|
"confidence": "confirmed",
|
|
"sort_order": 80
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "catalyst",
|
|
"title": "Catalyst",
|
|
"summary": "Speeds up a reaction without being used up",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:dimerization",
|
|
"relation": "related",
|
|
"label": "clay catalyst"
|
|
}
|
|
],
|
|
"sort_order": 90
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "distillation",
|
|
"title": "Distillation",
|
|
"summary": "Separating by boiling point",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:distillation",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 100
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "viscosity",
|
|
"title": "Viscosity",
|
|
"summary": "How thick something flows",
|
|
"confidence": "confirmed",
|
|
"sort_order": 110
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "acid-value",
|
|
"title": "Acid value",
|
|
"summary": "How many free acid ends a sample has",
|
|
"confidence": "confirmed",
|
|
"sort_order": 120
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "carbon",
|
|
"title": "Carbon (C)",
|
|
"summary": "The backbone builder; links into long chains",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"atom"
|
|
],
|
|
"sort_order": 130
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "hydrogen",
|
|
"title": "Hydrogen (H)",
|
|
"summary": "Small, sticks onto everything",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"atom"
|
|
],
|
|
"sort_order": 140
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "oxygen",
|
|
"title": "Oxygen (O)",
|
|
"summary": "Gives molecules reactive handles to grab",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"atom"
|
|
],
|
|
"sort_order": 150
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "nitrogen",
|
|
"title": "Nitrogen (N)",
|
|
"summary": "Shows up in some glues and plastics",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"atom"
|
|
],
|
|
"sort_order": 160
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "saturated",
|
|
"title": "Saturated",
|
|
"summary": "Straight teeth that stack neatly, so the fat is solid at room temperature, like butter",
|
|
"confidence": "confirmed",
|
|
"sort_order": 170
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "unsaturated",
|
|
"title": "Unsaturated",
|
|
"summary": "Kinked teeth (a double bond) that can't stack, so the oil stays liquid, like sunflower oil",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "glossary:double-bond",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 180
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "pastillator",
|
|
"title": "Pastillator",
|
|
"summary": "Drips hot liquid onto a cooled steel belt so each drop freezes into a pellet",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "process_step:pellet-belt",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 190
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "immersion-cooling",
|
|
"title": "Immersion cooling",
|
|
"summary": "Servers dunked in a liquid that doesn't conduct electricity",
|
|
"confidence": "confirmed",
|
|
"links": [
|
|
{
|
|
"to": "product:fr3",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 200
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "kolommen",
|
|
"title": "Kolommen",
|
|
"summary": "The (distillation) columns",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"dutch"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "process_step:distillation",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 210
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "dimeren",
|
|
"title": "Dimeren",
|
|
"summary": "Dimers; the dimerization unit",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"dutch"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "process_step:dimerization",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 220
|
|
},
|
|
{
|
|
"kind": "glossary",
|
|
"slug": "pillenband",
|
|
"title": "Pillenband",
|
|
"summary": "The pellet belt",
|
|
"confidence": "confirmed",
|
|
"tags": [
|
|
"dutch"
|
|
],
|
|
"links": [
|
|
{
|
|
"to": "process_step:pellet-belt",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 230
|
|
},
|
|
{
|
|
"kind": "question",
|
|
"slug": "which-oils",
|
|
"title": "Which oils come in at the start?",
|
|
"summary": "Soy, rapeseed, palm, or something else.",
|
|
"confidence": "open",
|
|
"links": [
|
|
{
|
|
"to": "product:plant-oil",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 10
|
|
},
|
|
{
|
|
"kind": "question",
|
|
"slug": "which-department",
|
|
"title": "Which department does which move?",
|
|
"summary": "Splitting, distillation, dimer, esters, FR3.",
|
|
"confidence": "open",
|
|
"links": [
|
|
{
|
|
"to": "process_step:splitting",
|
|
"relation": "related"
|
|
},
|
|
{
|
|
"to": "process_step:distillation",
|
|
"relation": "related"
|
|
},
|
|
{
|
|
"to": "process_step:dimerization",
|
|
"relation": "related"
|
|
},
|
|
{
|
|
"to": "process_step:esterification",
|
|
"relation": "related"
|
|
},
|
|
{
|
|
"to": "product:fr3",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 20
|
|
},
|
|
{
|
|
"kind": "question",
|
|
"slug": "pillenband-pellets",
|
|
"title": "What does the pillenband make pellets of?",
|
|
"body": "Worth confirming at work: if liquid drips onto a belt and pellets come off the end, it's a pastillator.",
|
|
"confidence": "open",
|
|
"links": [
|
|
{
|
|
"to": "process_step:pellet-belt",
|
|
"relation": "related"
|
|
}
|
|
],
|
|
"sort_order": 30
|
|
},
|
|
{
|
|
"kind": "question",
|
|
"slug": "in-and-out",
|
|
"title": "For any unit: what goes in and what comes out?",
|
|
"summary": "This one question turns the whole plant into a flow diagram in your head.",
|
|
"confidence": "open",
|
|
"sort_order": 40
|
|
}
|
|
]
|
|
}
|
|
$seed$::jsonb);
|
|
--> statement-breakpoint
|
|
-- Privacy: owner-only on the site (ownerOnlyPrefixes). Like notes and tasks, only the app role
|
|
-- can read or write (no personal_ro). Grants only run on prod (migrations as `personal`).
|
|
REVOKE ALL ON SCHEMA plant FROM PUBLIC;
|
|
--> statement-breakpoint
|
|
DO $$
|
|
BEGIN
|
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
|
GRANT USAGE ON SCHEMA plant TO personal_app;
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA plant TO personal_app;
|
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA plant TO personal_app;
|
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA plant TO personal_app;
|
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA plant
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA plant
|
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
|
END IF;
|
|
END $$;
|