plant: private Cargill Gouda tracker (/trackers/cargill)
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.
This commit is contained in:
2026-10-26 13:42:37 +00:00
parent fda21554cc
commit e24ee7f95d
26 changed files with 48481 additions and 3 deletions

View File

@@ -34,3 +34,4 @@ export * from './worldtodo';
export * from './osu-goals';
export * from './notes';
export * from './math';
export * from './plant';

View File

@@ -0,0 +1,118 @@
import { sql } from 'drizzle-orm';
import {
boolean,
check,
index,
integer,
text,
uniqueIndex,
type AnyPgColumn
} from 'drizzle-orm/pg-core';
import { plantSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Cargill plant tracker (/trackers/cargill, owner-only): what the Cargill Bioindustrial plant
// does. One generic `entries` table (departments, products, process steps, equipment, glossary,
// questions, notes, roles) plus typed `links` between them. Bots apply JSON with
// `select plant.apply_entries('{...}'::jsonb)` (shape documented in the migration and in
// docs/plant-tracker.md). Kinds, confidences and relations are kept in sync with #lib/plant.
export const plantEntries = plantSchema.table(
'entries',
{
id: id(),
/** process_step | product | department | equipment | glossary | question | note | person_role */
kind: text('kind').notNull(),
/** Unique per kind; used in URLs and by apply_entries. */
slug: text('slug').notNull(),
title: text('title').notNull(),
summary: text('summary'),
/** Markdown. */
body: text('body'),
parentId: integer('parent_id').references((): AnyPgColumn => plantEntries.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
sortOrder: integer('sort_order').notNull().default(0),
/** Where it came from, e.g. "page 2026-10-26", "Walter White", "Stan". */
source: text('source'),
/** confirmed (checked at work) | assumed (from the guide) | open (unknown). */
confidence: text('confidence').notNull().default('assumed'),
/** Questions: when it was answered (null = still open). */
answeredAt: timestamptz('answered_at'),
...timestamps
},
(t) => [
uniqueIndex('entries_kind_slug_key').on(t.kind, t.slug),
index('entries_parent_idx').on(t.parentId),
check(
'entries_kind_check',
sql`${t.kind} IN ('process_step','product','department','equipment','glossary','question','note','person_role')`
),
check('entries_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
check('entries_slug_check', sql`${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`)
]
);
export const plantLinks = plantSchema.table(
'links',
{
id: id(),
fromId: integer('from_id')
.notNull()
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
toId: integer('to_id')
.notNull()
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** feeds_into | made_in | uses | part_of | related */
relation: text('relation').notNull(),
/** Short edge label, e.g. "fatty acids". */
label: text('label'),
/** Drawn dashed in the flow (optional path or a loop back); doesn't push layers down. */
dashed: boolean('dashed').notNull().default(false),
/** confirmed | assumed | open; not-confirmed links are drawn dashed with a "?". */
confidence: text('confidence').notNull().default('confirmed'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
check('links_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
uniqueIndex('links_from_to_relation_key').on(t.fromId, t.toId, t.relation),
index('links_to_idx').on(t.toId),
check(
'links_relation_check',
sql`${t.relation} IN ('feeds_into','made_in','uses','part_of','related')`
),
check('links_not_self_check', sql`${t.fromId} <> ${t.toId}`)
]
);
/** Parents and links whose target isn't in the tracker yet. apply_entries retries them on every
* run, so a later batch that adds the target resolves them; the detail page lists them as pending. */
export const plantPending = plantSchema.table(
'pending',
{
id: id(),
fromId: integer('from_id')
.notNull()
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** "parent" or a link relation. */
field: text('field').notNull(),
/** The reference as given ("slug" or "kind:slug"). */
toRef: text('to_ref').notNull(),
label: text('label'),
dashed: boolean('dashed').notNull().default(false),
confidence: text('confidence').notNull().default('confirmed'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
uniqueIndex('pending_from_field_ref_key').on(t.fromId, t.field, t.toRef),
check(
'pending_field_check',
sql`${t.field} IN ('parent','feeds_into','made_in','uses','part_of','related')`
)
]
);

View File

@@ -30,3 +30,4 @@ export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes');
export const mathSchema = pgSchema('math');
export const plantSchema = pgSchema('plant');