people-truth 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Momolibrary
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # people-truth
2
+
3
+ `people-truth` is a Supabase/Postgres-backed source of truth for stable person identities, names, mentions, affiliations, identifiers, and sourced facts.
4
+
5
+ Supabase is treated as a replaceable Postgres runtime. The durable contract is the SQL migration plus the CLI behavior.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g people-truth
11
+ ptruth --help
12
+ ```
13
+
14
+ For local development:
15
+
16
+ ```bash
17
+ npm install
18
+ npm run build
19
+ npm run dev -- --help
20
+ ```
21
+
22
+ ## Configure
23
+
24
+ Create a local `.env` file:
25
+
26
+ ```bash
27
+ ptruth init --url "https://your-project-ref.supabase.co" --key "your-service-role-or-secret-key"
28
+ ptruth status
29
+ ```
30
+
31
+ The CLI reads `SUPABASE_URL` and `SUPABASE_KEY` from `.env`, or from `~/.codex/.env` when present.
32
+
33
+ ## Database
34
+
35
+ Apply migrations to a linked Supabase project:
36
+
37
+ ```bash
38
+ npx supabase link --project-ref your-project-ref
39
+ npm run db:push
40
+ ```
41
+
42
+ The main files are:
43
+
44
+ - `supabase/migrations/20260611000000_initial_people_truth.sql`
45
+ - `supabase/schema.sql`
46
+
47
+ The schema centers on:
48
+
49
+ - `people`: stable UUID identity.
50
+ - `person_names`: canonical names, aliases, pen names, former names, foreign names.
51
+ - `person_identifiers`: external IDs and URLs.
52
+ - `person_affiliations`: organization and role history.
53
+ - `person_facts`: sourced predicate/value facts.
54
+ - `sources`: provenance.
55
+
56
+ ## CLI
57
+
58
+ ```bash
59
+ ptruth people add "梁启超" --alias "梁任公" --alias "饮冰室主人"
60
+ ptruth people search "梁任公"
61
+ ptruth people show "梁启超"
62
+ ptruth people mention "梁启超"
63
+ ptruth facts add "梁启超" wrote "饮冰室合集" --source manual --confidence likely
64
+ ptruth facts list "梁启超"
65
+ ```
66
+
67
+ All commands support JSON output:
68
+
69
+ ```bash
70
+ ptruth people show "梁启超" -o json
71
+ ```
72
+
73
+ ## Mentions
74
+
75
+ Use Markdown-compatible person mentions:
76
+
77
+ ```md
78
+ [梁启超](person:2f7f0f2c-3d58-4d85-8e36-8d1e5fd2d7c9)
79
+ ```
80
+
81
+ The label preserves the text as written. The `person:uuid` target preserves the stable identity.
82
+
83
+ ## Releases
84
+
85
+ This repository uses GitHub Actions and semantic-release. Push conventional commits to `main`:
86
+
87
+ ```text
88
+ feat: add identifier commands
89
+ fix: handle duplicate person names
90
+ feat!: change mention format
91
+ ```
92
+
93
+ CI runs typecheck/build/pack. Release publishes to npm when `NPM_TOKEN` is configured in GitHub repository secrets.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { existsSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { addFact, listFacts } from "./facts.js";
6
+ import { getConfig } from "./config.js";
7
+ import { addPerson, formatPersonMention, getPerson, searchPeople } from "./people.js";
8
+ import { printResult } from "./output.js";
9
+ const program = new Command();
10
+ program
11
+ .name("people-truth")
12
+ .description("Supabase-backed personal source of truth for people.")
13
+ .option("-o, --output <mode>", "Output mode: table or json", "table");
14
+ program
15
+ .command("init")
16
+ .description("Create a local .env file for Supabase credentials")
17
+ .requiredOption("--url <url>", "Supabase project URL")
18
+ .requiredOption("--key <key>", "Supabase service-role or secret key")
19
+ .option("--path <path>", "Env file path", ".env")
20
+ .option("--force", "Overwrite an existing env file", false)
21
+ .action(async (options) => {
22
+ await run(async () => {
23
+ const envPath = resolve(options.path);
24
+ if (existsSync(envPath) && !options.force) {
25
+ throw new Error(`${envPath} already exists. Re-run with --force to overwrite it.`);
26
+ }
27
+ writeFileSync(envPath, `SUPABASE_URL="${options.url}"\nSUPABASE_KEY="${options.key}"\n`, {
28
+ mode: 0o600
29
+ });
30
+ printResult({ ok: true, path: envPath }, outputMode());
31
+ });
32
+ });
33
+ program
34
+ .command("status")
35
+ .description("Check local CLI configuration")
36
+ .action(async () => {
37
+ await run(async () => {
38
+ const config = getConfig();
39
+ printResult({
40
+ ok: true,
41
+ supabaseUrl: config.SUPABASE_URL,
42
+ hasSupabaseKey: Boolean(config.SUPABASE_KEY)
43
+ }, outputMode());
44
+ });
45
+ });
46
+ const people = program.command("people").description("Manage people");
47
+ people
48
+ .command("add")
49
+ .argument("<name>", "Canonical name")
50
+ .option("--alias <alias...>", "Alias or nickname")
51
+ .option("--organization <organization>", "Organization")
52
+ .option("--role <role>", "Role or title")
53
+ .option("--location <location>", "Location")
54
+ .option("--relationship <relationship>", "Relationship context")
55
+ .option("--bio <bio>", "Short biography")
56
+ .option("--note <note>", "Private note")
57
+ .action(async (name, options) => {
58
+ await run(async () => {
59
+ const result = await addPerson({
60
+ name,
61
+ aliases: options.alias ?? [],
62
+ organization: options.organization,
63
+ role: options.role,
64
+ location: options.location,
65
+ relationship: options.relationship,
66
+ bio: options.bio,
67
+ note: options.note
68
+ });
69
+ printResult(result, outputMode());
70
+ });
71
+ });
72
+ people
73
+ .command("search")
74
+ .argument("<query>", "Search query")
75
+ .action(async (query) => {
76
+ await run(async () => {
77
+ printResult(await searchPeople(query), outputMode());
78
+ });
79
+ });
80
+ people
81
+ .command("show")
82
+ .argument("<identifier>", "Person ID or alias")
83
+ .action(async (identifier) => {
84
+ await run(async () => {
85
+ const result = await getPerson(identifier);
86
+ if (!result)
87
+ throw new Error(`Person not found: ${identifier}`);
88
+ printResult(result, outputMode());
89
+ });
90
+ });
91
+ people
92
+ .command("mention")
93
+ .argument("<identifier>", "Person ID or name")
94
+ .option("--label <label>", "Display label to preserve in text")
95
+ .action(async (identifier, options) => {
96
+ await run(async () => {
97
+ printResult(await formatPersonMention(identifier, options.label), outputMode());
98
+ });
99
+ });
100
+ const facts = program.command("facts").description("Manage person facts");
101
+ facts
102
+ .command("add")
103
+ .argument("<person>", "Person ID or alias")
104
+ .argument("<predicate>", "Fact predicate, e.g. works_at")
105
+ .argument("<value>", "Fact value")
106
+ .option("--confidence <confidence>", "confirmed, likely, or unverified", "unverified")
107
+ .option("--source <sourceType>", "Source type, e.g. manual, getnote, meeting")
108
+ .option("--source-ref <sourceRef>", "Source reference, e.g. note ID")
109
+ .option("--source-title <sourceTitle>", "Source title")
110
+ .option("--source-url <sourceUrl>", "Source URL")
111
+ .action(async (person, predicate, value, options) => {
112
+ await run(async () => {
113
+ const confidence = options.confidence;
114
+ if (!["confirmed", "likely", "unverified"].includes(confidence)) {
115
+ throw new Error("Confidence must be confirmed, likely, or unverified.");
116
+ }
117
+ printResult(await addFact({
118
+ person,
119
+ predicate,
120
+ value,
121
+ confidence,
122
+ sourceType: options.source,
123
+ sourceRef: options.sourceRef,
124
+ sourceTitle: options.sourceTitle,
125
+ sourceUrl: options.sourceUrl
126
+ }), outputMode());
127
+ });
128
+ });
129
+ facts
130
+ .command("list")
131
+ .argument("<person>", "Person ID or alias")
132
+ .action(async (person) => {
133
+ await run(async () => {
134
+ printResult(await listFacts(person), outputMode());
135
+ });
136
+ });
137
+ function outputMode() {
138
+ const mode = program.opts().output;
139
+ return mode === "json" ? "json" : "table";
140
+ }
141
+ async function run(fn) {
142
+ try {
143
+ await fn();
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ console.error(message);
148
+ process.exitCode = 1;
149
+ }
150
+ }
151
+ program.parseAsync();
@@ -0,0 +1,4 @@
1
+ export declare function getConfig(): {
2
+ SUPABASE_URL: string;
3
+ SUPABASE_KEY: string;
4
+ };
package/dist/config.js ADDED
@@ -0,0 +1,21 @@
1
+ import { config as loadDotenv } from "dotenv";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ loadDotenv();
7
+ const codexEnvPath = join(homedir(), ".codex", ".env");
8
+ if (existsSync(codexEnvPath)) {
9
+ loadDotenv({ path: codexEnvPath, override: false });
10
+ }
11
+ const envSchema = z.object({
12
+ SUPABASE_URL: z.string().url(),
13
+ SUPABASE_KEY: z.string().min(1)
14
+ });
15
+ export function getConfig() {
16
+ const parsed = envSchema.safeParse(process.env);
17
+ if (!parsed.success) {
18
+ throw new Error("Missing Supabase config. Set SUPABASE_URL and SUPABASE_KEY in .env or ~/.codex/.env.");
19
+ }
20
+ return parsed.data;
21
+ }
@@ -0,0 +1,11 @@
1
+ export declare function addFact(input: {
2
+ person: string;
3
+ predicate: string;
4
+ value: string;
5
+ confidence: "confirmed" | "likely" | "unverified";
6
+ sourceType?: string;
7
+ sourceRef?: string;
8
+ sourceTitle?: string;
9
+ sourceUrl?: string;
10
+ }): Promise<any>;
11
+ export declare function listFacts(identifier: string): Promise<any>;
package/dist/facts.js ADDED
@@ -0,0 +1,44 @@
1
+ import { getPerson } from "./people.js";
2
+ import { getSupabase } from "./supabase.js";
3
+ export async function addFact(input) {
4
+ const supabase = getSupabase();
5
+ const person = await getPerson(input.person);
6
+ if (!person)
7
+ throw new Error(`Person not found: ${input.person}`);
8
+ let sourceId = null;
9
+ if (input.sourceType || input.sourceRef || input.sourceTitle || input.sourceUrl) {
10
+ const { data: source, error: sourceError } = await supabase
11
+ .from("sources")
12
+ .insert({
13
+ source_type: input.sourceType ?? "manual",
14
+ source_ref: input.sourceRef,
15
+ title: input.sourceTitle,
16
+ url: input.sourceUrl
17
+ })
18
+ .select()
19
+ .single();
20
+ if (sourceError)
21
+ throw new Error(sourceError.message);
22
+ sourceId = source.id;
23
+ }
24
+ const { data, error } = await supabase
25
+ .from("person_facts")
26
+ .insert({
27
+ person_id: person.id,
28
+ predicate: input.predicate,
29
+ value: input.value,
30
+ confidence: input.confidence,
31
+ source_id: sourceId
32
+ })
33
+ .select("*, sources(source_type, source_ref, title, url)")
34
+ .single();
35
+ if (error)
36
+ throw new Error(error.message);
37
+ return data;
38
+ }
39
+ export async function listFacts(identifier) {
40
+ const person = await getPerson(identifier);
41
+ if (!person)
42
+ throw new Error(`Person not found: ${identifier}`);
43
+ return person.person_facts ?? [];
44
+ }
@@ -0,0 +1,2 @@
1
+ export type OutputMode = "table" | "json";
2
+ export declare function printResult(value: unknown, mode: OutputMode): void;
package/dist/output.js ADDED
@@ -0,0 +1,15 @@
1
+ export function printResult(value, mode) {
2
+ if (mode === "json") {
3
+ console.log(JSON.stringify(value, null, 2));
4
+ return;
5
+ }
6
+ if (Array.isArray(value)) {
7
+ console.table(value);
8
+ return;
9
+ }
10
+ if (typeof value === "string") {
11
+ console.log(value);
12
+ return;
13
+ }
14
+ console.dir(value, { depth: null, colors: true });
15
+ }
@@ -0,0 +1,13 @@
1
+ export declare function addPerson(input: {
2
+ name: string;
3
+ aliases: string[];
4
+ organization?: string;
5
+ role?: string;
6
+ location?: string;
7
+ relationship?: string;
8
+ bio?: string;
9
+ note?: string;
10
+ }): Promise<any>;
11
+ export declare function searchPeople(query: string): Promise<unknown[]>;
12
+ export declare function getPerson(identifier: string): Promise<any>;
13
+ export declare function formatPersonMention(identifier: string, label?: string): Promise<string>;
package/dist/people.js ADDED
@@ -0,0 +1,106 @@
1
+ import { getSupabase } from "./supabase.js";
2
+ const PERSON_SELECT = "*, person_names(name, name_type, locale, is_primary), person_identifiers(identifier_type, value, url, is_primary), person_affiliations(organization_name, role_title, start_date, end_date, note), person_facts(id, predicate, value, confidence, valid_from, valid_to, created_at, sources(source_type, source_ref, title, url))";
3
+ export async function addPerson(input) {
4
+ const supabase = getSupabase();
5
+ const { data: person, error } = await supabase
6
+ .from("people")
7
+ .insert({
8
+ canonical_name: input.name,
9
+ display_name: input.name,
10
+ bio: input.bio,
11
+ note: input.note ?? ([input.location, input.relationship].filter(Boolean).join("; ") || undefined)
12
+ })
13
+ .select()
14
+ .single();
15
+ if (error)
16
+ throw new Error(error.message);
17
+ const names = Array.from(new Set([input.name, ...input.aliases].filter(Boolean)));
18
+ if (names.length > 0) {
19
+ const { error: nameError } = await supabase.from("person_names").insert(names.map((alias) => ({
20
+ person_id: person.id,
21
+ name: alias,
22
+ name_type: alias === input.name ? "canonical" : "alias",
23
+ is_primary: alias === input.name
24
+ })));
25
+ if (nameError)
26
+ throw new Error(nameError.message);
27
+ }
28
+ if (input.organization) {
29
+ const { error: affiliationError } = await supabase.from("person_affiliations").insert({
30
+ person_id: person.id,
31
+ organization_name: input.organization,
32
+ role_title: input.role
33
+ });
34
+ if (affiliationError)
35
+ throw new Error(affiliationError.message);
36
+ }
37
+ return getPerson(person.id);
38
+ }
39
+ export async function searchPeople(query) {
40
+ const supabase = getSupabase();
41
+ const pattern = `%${query}%`;
42
+ const select = "id, canonical_name, display_name, bio, note, person_names(name, name_type), person_affiliations(organization_name, role_title)";
43
+ const { data: directMatches, error } = await supabase
44
+ .from("people")
45
+ .select(select)
46
+ .or(`canonical_name.ilike.${pattern},display_name.ilike.${pattern},bio.ilike.${pattern},note.ilike.${pattern}`)
47
+ .limit(20);
48
+ if (error)
49
+ throw new Error(error.message);
50
+ const { data: nameMatches, error: nameError } = await supabase
51
+ .from("person_names")
52
+ .select(`people(${select})`)
53
+ .ilike("name", pattern)
54
+ .limit(20);
55
+ if (nameError)
56
+ throw new Error(nameError.message);
57
+ const byId = new Map();
58
+ for (const person of directMatches ?? []) {
59
+ byId.set(person.id, person);
60
+ }
61
+ for (const row of nameMatches ?? []) {
62
+ const person = Array.isArray(row.people) ? row.people[0] : row.people;
63
+ if (person?.id)
64
+ byId.set(person.id, person);
65
+ }
66
+ return Array.from(byId.values()).slice(0, 20);
67
+ }
68
+ export async function getPerson(identifier) {
69
+ const supabase = getSupabase();
70
+ const { data: personById, error: idError } = await supabase
71
+ .from("people")
72
+ .select(PERSON_SELECT)
73
+ .eq("id", identifier)
74
+ .maybeSingle();
75
+ if (idError)
76
+ throw new Error(idError.message);
77
+ if (personById)
78
+ return personById;
79
+ const { data: nameMatches, error: aliasError } = await supabase
80
+ .from("person_names")
81
+ .select("person_id")
82
+ .ilike("name", identifier)
83
+ .limit(2);
84
+ if (aliasError)
85
+ throw new Error(aliasError.message);
86
+ if (!nameMatches || nameMatches.length === 0)
87
+ return null;
88
+ if (nameMatches.length > 1) {
89
+ throw new Error(`Multiple people match "${identifier}". Use a UUID instead.`);
90
+ }
91
+ const { data, error } = await supabase
92
+ .from("people")
93
+ .select(PERSON_SELECT)
94
+ .eq("id", nameMatches[0].person_id)
95
+ .single();
96
+ if (error)
97
+ throw new Error(error.message);
98
+ return data;
99
+ }
100
+ export async function formatPersonMention(identifier, label) {
101
+ const person = await getPerson(identifier);
102
+ if (!person)
103
+ throw new Error(`Person not found: ${identifier}`);
104
+ const display = label ?? person.display_name ?? person.canonical_name;
105
+ return `[${display}](person:${person.id})`;
106
+ }
@@ -0,0 +1 @@
1
+ export declare function getSupabase(): import("@supabase/supabase-js").SupabaseClient<any, "public", "public", any, any>;
@@ -0,0 +1,11 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import { getConfig } from "./config.js";
3
+ export function getSupabase() {
4
+ const { SUPABASE_URL, SUPABASE_KEY } = getConfig();
5
+ return createClient(SUPABASE_URL, SUPABASE_KEY, {
6
+ auth: {
7
+ persistSession: false,
8
+ autoRefreshToken: false
9
+ }
10
+ });
11
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "people-truth",
3
+ "version": "1.0.0",
4
+ "description": "A Supabase-backed personal source of truth for people and relationship facts.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/momolibrary/people-truth.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/momolibrary/people-truth/issues"
13
+ },
14
+ "homepage": "https://github.com/momolibrary/people-truth#readme",
15
+ "bin": {
16
+ "people-truth": "dist/cli.js",
17
+ "ptruth": "dist/cli.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "supabase",
22
+ "skills",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "dev": "tsx src/cli.ts",
32
+ "db:push": "supabase db push",
33
+ "db:reset": "supabase db reset",
34
+ "pack:dry": "npm pack --dry-run",
35
+ "prepack": "npm run build",
36
+ "release": "semantic-release",
37
+ "start": "node dist/cli.js",
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "dependencies": {
41
+ "@supabase/supabase-js": "^2.108.1",
42
+ "commander": "^12.1.0",
43
+ "dotenv": "^16.6.1",
44
+ "zod": "^3.25.76"
45
+ },
46
+ "devDependencies": {
47
+ "@semantic-release/changelog": "^6.0.3",
48
+ "@semantic-release/commit-analyzer": "^13.0.1",
49
+ "@semantic-release/git": "^10.0.1",
50
+ "@semantic-release/github": "^12.0.8",
51
+ "@semantic-release/npm": "^13.1.5",
52
+ "@semantic-release/release-notes-generator": "^14.1.1",
53
+ "@types/node": "^22.10.2",
54
+ "conventional-changelog-conventionalcommits": "^9.3.1",
55
+ "semantic-release": "^25.0.5",
56
+ "supabase": "^2.105.0",
57
+ "tsx": "^4.19.2",
58
+ "typescript": "^5.7.2"
59
+ },
60
+ "engines": {
61
+ "node": ">=20"
62
+ }
63
+ }
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: people-truth
3
+ description: Use the people-truth CLI and Supabase/Postgres schema when Codex needs to create, query, migrate, or reason about stable person identities, names, markdown person mentions, affiliations, identifiers, and sourced person facts in this repository or an installed people-truth CLI.
4
+ ---
5
+
6
+ # People Truth
7
+
8
+ ## Core Model
9
+
10
+ Treat Supabase as a replaceable Postgres runtime. The durable contract is the migration SQL and CLI behavior in this repository.
11
+
12
+ - `people.id` is the stable UUID identity.
13
+ - `person_names` stores canonical names, display names, aliases, pen names, former names, foreign names, and other human labels.
14
+ - `person_identifiers` stores external IDs such as email, website, Wikidata, GitHub, X/Twitter, or custom IDs.
15
+ - `person_affiliations` stores organization and role history without making organizations a first-class ontology yet.
16
+ - `person_facts` stores sourced predicate/value facts with `confirmed`, `likely`, or `unverified` confidence.
17
+ - `sources` stores provenance for facts.
18
+
19
+ Use this mention format in prose and notes:
20
+
21
+ ```md
22
+ [Display Name](person:uuid)
23
+ ```
24
+
25
+ Preserve the display label as written. Resolve the `person:uuid` target when current canonical data is needed.
26
+
27
+ ## CLI Workflow
28
+
29
+ Use `ptruth` when installed globally, or `npm run dev --` inside the repository.
30
+
31
+ ```bash
32
+ ptruth init --url "$SUPABASE_URL" --key "$SUPABASE_KEY"
33
+ ptruth status
34
+ ptruth people add "梁启超" --alias "梁任公" --alias "饮冰室主人"
35
+ ptruth people search "梁任公"
36
+ ptruth people show "梁启超" -o json
37
+ ptruth people mention "梁启超"
38
+ ptruth facts add "梁启超" wrote "饮冰室合集" --source manual --confidence likely
39
+ ptruth facts list "梁启超" -o json
40
+ ```
41
+
42
+ When writing automation or agent integrations, prefer `-o json` and parse stdout as JSON.
43
+
44
+ ## Migration Workflow
45
+
46
+ Use `supabase/migrations/*.sql` as the source of truth. Keep `supabase/schema.sql` as a readable flattened snapshot of the current schema.
47
+
48
+ For local or linked Supabase projects:
49
+
50
+ ```bash
51
+ npm run db:push
52
+ ```
53
+
54
+ For other Postgres hosts, run the SQL files in migration order with any standard Postgres migration runner.
55
+
56
+ ## Release Workflow
57
+
58
+ Use conventional commits so GitHub Actions and semantic-release can publish npm automatically:
59
+
60
+ - `feat: add person identifiers` creates a minor release.
61
+ - `fix: handle duplicate names` creates a patch release.
62
+ - `feat!: change mention syntax` creates a major release.
63
+ - `chore:` normally does not release.
64
+
65
+ The CLI package is intended to work with:
66
+
67
+ ```bash
68
+ npm install -g people-truth
69
+ ptruth --help
70
+ ```
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "People Truth"
3
+ short_description: "Manage stable person identities with CLI."
4
+ default_prompt: "Use people-truth to manage stable person identities, mentions, and sourced facts."
@@ -0,0 +1,413 @@
1
+ # For detailed configuration reference documentation, visit:
2
+ # https://supabase.com/docs/guides/local-development/cli/config
3
+ # A string used to distinguish different Supabase projects on the same host. Defaults to the
4
+ # working directory name when running `supabase init`.
5
+ project_id = "people-truth"
6
+
7
+ [api]
8
+ enabled = true
9
+ # Port to use for the API URL.
10
+ port = 54321
11
+ # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
12
+ # endpoints. `public` and `graphql_public` schemas are included by default.
13
+ schemas = ["public", "graphql_public"]
14
+ # Extra schemas to add to the search_path of every request.
15
+ extra_search_path = ["public", "extensions"]
16
+ # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
17
+ # for accidental or malicious requests.
18
+ max_rows = 1000
19
+ # Controls whether new tables, views, sequences and functions created in the `public` schema by
20
+ # `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
21
+ # without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default
22
+ # flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in
23
+ # 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early.
24
+ # auto_expose_new_tables = false
25
+
26
+ [api.tls]
27
+ # Enable HTTPS endpoints locally using a self-signed certificate.
28
+ enabled = false
29
+ # Paths to self-signed certificate pair.
30
+ # cert_path = "../certs/my-cert.pem"
31
+ # key_path = "../certs/my-key.pem"
32
+
33
+ [db]
34
+ # Port to use for the local database URL.
35
+ port = 54322
36
+ # Port used by db diff command to initialize the shadow database.
37
+ shadow_port = 54320
38
+ # Maximum amount of time to wait for health check when starting the local database.
39
+ health_timeout = "2m"
40
+ # The database major version to use. This has to be the same as your remote database's. Run `SHOW
41
+ # server_version;` on the remote database to check.
42
+ major_version = 17
43
+
44
+ [db.pooler]
45
+ enabled = false
46
+ # Port to use for the local connection pooler.
47
+ port = 54329
48
+ # Specifies when a server connection can be reused by other clients.
49
+ # Configure one of the supported pooler modes: `transaction`, `session`.
50
+ pool_mode = "transaction"
51
+ # How many server connections to allow per user/database pair.
52
+ default_pool_size = 20
53
+ # Maximum number of client connections allowed.
54
+ max_client_conn = 100
55
+
56
+ # [db.vault]
57
+ # secret_key = "env(SECRET_VALUE)"
58
+
59
+ [db.migrations]
60
+ # If disabled, migrations will be skipped during a db push or reset.
61
+ enabled = true
62
+ # Specifies an ordered list of schema files that describe your database.
63
+ # Supports glob patterns relative to supabase directory: "./schemas/*.sql"
64
+ schema_paths = []
65
+
66
+ [db.seed]
67
+ # If enabled, seeds the database after migrations during a db reset.
68
+ enabled = false
69
+ # Specifies an ordered list of seed files to load during db reset.
70
+ # Supports glob patterns relative to supabase directory: "./seeds/*.sql"
71
+ sql_paths = ["./seed.sql"]
72
+
73
+ [db.network_restrictions]
74
+ # Enable management of network restrictions.
75
+ enabled = false
76
+ # List of IPv4 CIDR blocks allowed to connect to the database.
77
+ # Defaults to allow all IPv4 connections. Set empty array to block all IPs.
78
+ allowed_cidrs = ["0.0.0.0/0"]
79
+ # List of IPv6 CIDR blocks allowed to connect to the database.
80
+ # Defaults to allow all IPv6 connections. Set empty array to block all IPs.
81
+ allowed_cidrs_v6 = ["::/0"]
82
+
83
+ # Uncomment to reject non-secure connections to the database.
84
+ # [db.ssl_enforcement]
85
+ # enabled = true
86
+
87
+ [realtime]
88
+ enabled = true
89
+ # Bind realtime via either IPv4 or IPv6. (default: IPv4)
90
+ # ip_version = "IPv6"
91
+ # The maximum length in bytes of HTTP request headers. (default: 4096)
92
+ # max_header_length = 4096
93
+
94
+ [studio]
95
+ enabled = true
96
+ # Port to use for Supabase Studio.
97
+ port = 54323
98
+ # External URL of the API server that frontend connects to.
99
+ api_url = "http://127.0.0.1"
100
+ # OpenAI API Key to use for Supabase AI in the Supabase Studio.
101
+ openai_api_key = "env(OPENAI_API_KEY)"
102
+
103
+ # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
104
+ # are monitored, and you can view the emails that would have been sent from the web interface.
105
+ [inbucket]
106
+ enabled = true
107
+ # Port to use for the email testing server web interface.
108
+ port = 54324
109
+ # Uncomment to expose additional ports for testing user applications that send emails.
110
+ # smtp_port = 54325
111
+ # pop3_port = 54326
112
+ # admin_email = "admin@email.com"
113
+ # sender_name = "Admin"
114
+
115
+ [storage]
116
+ enabled = true
117
+ # The maximum file size allowed (e.g. "5MB", "500KB").
118
+ file_size_limit = "50MiB"
119
+
120
+ # Uncomment to configure local storage buckets
121
+ # [storage.buckets.images]
122
+ # public = false
123
+ # file_size_limit = "50MiB"
124
+ # allowed_mime_types = ["image/png", "image/jpeg"]
125
+ # objects_path = "./images"
126
+
127
+ # Allow connections via S3 compatible clients
128
+ [storage.s3_protocol]
129
+ enabled = true
130
+
131
+ # Image transformation API is available to Supabase Pro plan.
132
+ # [storage.image_transformation]
133
+ # enabled = true
134
+
135
+ # Store analytical data in S3 for running ETL jobs over Iceberg Catalog
136
+ # This feature is only available on the hosted platform.
137
+ [storage.analytics]
138
+ enabled = false
139
+ max_namespaces = 5
140
+ max_tables = 10
141
+ max_catalogs = 2
142
+
143
+ # Analytics Buckets is available to Supabase Pro plan.
144
+ # [storage.analytics.buckets.my-warehouse]
145
+
146
+ # Store vector embeddings in S3 for large and durable datasets
147
+ [storage.vector]
148
+ enabled = true
149
+ max_buckets = 10
150
+ max_indexes = 5
151
+
152
+ # Vector Buckets is available to Supabase Pro plan.
153
+ # [storage.vector.buckets.documents-openai]
154
+
155
+ [auth]
156
+ enabled = true
157
+ # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
158
+ # in emails.
159
+ site_url = "http://127.0.0.1:3000"
160
+ # The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
161
+ # external_url = ""
162
+ # A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
163
+ additional_redirect_urls = ["https://127.0.0.1:3000"]
164
+ # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
165
+ jwt_expiry = 3600
166
+ # JWT issuer URL. If not set, defaults to auth.external_url.
167
+ # jwt_issuer = ""
168
+ # Path to JWT signing key. DO NOT commit your signing keys file to git.
169
+ # signing_keys_path = "./signing_keys.json"
170
+ # If disabled, the refresh token will never expire.
171
+ enable_refresh_token_rotation = true
172
+ # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
173
+ # Requires enable_refresh_token_rotation = true.
174
+ refresh_token_reuse_interval = 10
175
+ # Allow/disallow new user signups to your project.
176
+ enable_signup = true
177
+ # Allow/disallow anonymous sign-ins to your project.
178
+ enable_anonymous_sign_ins = false
179
+ # Allow/disallow testing manual linking of accounts
180
+ enable_manual_linking = false
181
+ # Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
182
+ minimum_password_length = 6
183
+ # Passwords that do not meet the following requirements will be rejected as weak. Supported values
184
+ # are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
185
+ password_requirements = ""
186
+
187
+ # Configure passkey sign-ins.
188
+ # [auth.passkey]
189
+ # enabled = false
190
+
191
+ # Configure WebAuthn relying party settings (required when passkey is enabled).
192
+ # [auth.webauthn]
193
+ # rp_display_name = "Supabase"
194
+ # rp_id = "localhost"
195
+ # rp_origins = ["http://127.0.0.1:3000"]
196
+
197
+ [auth.rate_limit]
198
+ # Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
199
+ email_sent = 2
200
+ # Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
201
+ sms_sent = 30
202
+ # Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
203
+ anonymous_users = 30
204
+ # Number of sessions that can be refreshed in a 5 minute interval per IP address.
205
+ token_refresh = 150
206
+ # Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
207
+ sign_in_sign_ups = 30
208
+ # Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
209
+ token_verifications = 30
210
+ # Number of Web3 logins that can be made in a 5 minute interval per IP address.
211
+ web3 = 30
212
+
213
+ # Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
214
+ # [auth.captcha]
215
+ # enabled = true
216
+ # provider = "hcaptcha"
217
+ # secret = ""
218
+
219
+ [auth.email]
220
+ # Allow/disallow new user signups via email to your project.
221
+ enable_signup = true
222
+ # If enabled, a user will be required to confirm any email change on both the old, and new email
223
+ # addresses. If disabled, only the new email is required to confirm.
224
+ double_confirm_changes = true
225
+ # If enabled, users need to confirm their email address before signing in.
226
+ enable_confirmations = false
227
+ # If enabled, users will need to reauthenticate or have logged in recently to change their password.
228
+ secure_password_change = false
229
+ # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
230
+ max_frequency = "1s"
231
+ # Number of characters used in the email OTP.
232
+ otp_length = 6
233
+ # Number of seconds before the email OTP expires (defaults to 1 hour).
234
+ otp_expiry = 3600
235
+
236
+ # Use a production-ready SMTP server
237
+ # [auth.email.smtp]
238
+ # enabled = true
239
+ # host = "smtp.sendgrid.net"
240
+ # port = 587
241
+ # user = "apikey"
242
+ # pass = "env(SENDGRID_API_KEY)"
243
+ # admin_email = "admin@email.com"
244
+ # sender_name = "Admin"
245
+
246
+ # Uncomment to customize email template
247
+ # [auth.email.template.invite]
248
+ # subject = "You have been invited"
249
+ # content_path = "./supabase/templates/invite.html"
250
+
251
+ # Uncomment to customize notification email template
252
+ # [auth.email.notification.password_changed]
253
+ # enabled = true
254
+ # subject = "Your password has been changed"
255
+ # content_path = "./templates/password_changed_notification.html"
256
+
257
+ [auth.sms]
258
+ # Allow/disallow new user signups via SMS to your project.
259
+ enable_signup = false
260
+ # If enabled, users need to confirm their phone number before signing in.
261
+ enable_confirmations = false
262
+ # Template for sending OTP to users
263
+ template = "Your code is {{ `{{ .Code }}` }}"
264
+ # Controls the minimum amount of time that must pass before sending another sms otp.
265
+ max_frequency = "5s"
266
+
267
+ # Use pre-defined map of phone number to OTP for testing.
268
+ # [auth.sms.test_otp]
269
+ # 4152127777 = "123456"
270
+
271
+ # Configure logged in session timeouts.
272
+ # [auth.sessions]
273
+ # Force log out after the specified duration.
274
+ # timebox = "24h"
275
+ # Force log out if the user has been inactive longer than the specified duration.
276
+ # inactivity_timeout = "8h"
277
+
278
+ # This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
279
+ # [auth.hook.before_user_created]
280
+ # enabled = true
281
+ # uri = "pg-functions://postgres/auth/before-user-created-hook"
282
+
283
+ # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
284
+ # [auth.hook.custom_access_token]
285
+ # enabled = true
286
+ # uri = "pg-functions://<database>/<schema>/<hook_name>"
287
+
288
+ # Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
289
+ [auth.sms.twilio]
290
+ enabled = false
291
+ account_sid = ""
292
+ message_service_sid = ""
293
+ # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
294
+ auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
295
+
296
+ # Multi-factor-authentication is available to Supabase Pro plan.
297
+ [auth.mfa]
298
+ # Control how many MFA factors can be enrolled at once per user.
299
+ max_enrolled_factors = 10
300
+
301
+ # Control MFA via App Authenticator (TOTP)
302
+ [auth.mfa.totp]
303
+ enroll_enabled = false
304
+ verify_enabled = false
305
+
306
+ # Configure MFA via Phone Messaging
307
+ [auth.mfa.phone]
308
+ enroll_enabled = false
309
+ verify_enabled = false
310
+ otp_length = 6
311
+ template = "Your code is {{ `{{ .Code }}` }}"
312
+ max_frequency = "5s"
313
+
314
+ # Configure MFA via WebAuthn
315
+ # [auth.mfa.web_authn]
316
+ # enroll_enabled = true
317
+ # verify_enabled = true
318
+
319
+ # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
320
+ # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
321
+ # `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
322
+ [auth.external.apple]
323
+ enabled = false
324
+ client_id = ""
325
+ # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
326
+ secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
327
+ # Overrides the default auth callback URL derived from auth.external_url.
328
+ redirect_uri = ""
329
+ # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
330
+ # or any other third-party OIDC providers.
331
+ url = ""
332
+ # If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
333
+ skip_nonce_check = false
334
+ # If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
335
+ email_optional = false
336
+
337
+ # Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
338
+ # You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
339
+ [auth.web3.solana]
340
+ enabled = false
341
+
342
+ # Use Firebase Auth as a third-party provider alongside Supabase Auth.
343
+ [auth.third_party.firebase]
344
+ enabled = false
345
+ # project_id = "my-firebase-project"
346
+
347
+ # Use Auth0 as a third-party provider alongside Supabase Auth.
348
+ [auth.third_party.auth0]
349
+ enabled = false
350
+ # tenant = "my-auth0-tenant"
351
+ # tenant_region = "us"
352
+
353
+ # Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
354
+ [auth.third_party.aws_cognito]
355
+ enabled = false
356
+ # user_pool_id = "my-user-pool-id"
357
+ # user_pool_region = "us-east-1"
358
+
359
+ # Use Clerk as a third-party provider alongside Supabase Auth.
360
+ [auth.third_party.clerk]
361
+ enabled = false
362
+ # Obtain from https://clerk.com/setup/supabase
363
+ # domain = "example.clerk.accounts.dev"
364
+
365
+ # OAuth server configuration
366
+ [auth.oauth_server]
367
+ # Enable OAuth server functionality
368
+ enabled = false
369
+ # Path for OAuth consent flow UI
370
+ authorization_url_path = "/oauth/consent"
371
+ # Allow dynamic client registration
372
+ allow_dynamic_registration = false
373
+
374
+ [edge_runtime]
375
+ enabled = true
376
+ # Supported request policies: `oneshot`, `per_worker`.
377
+ # `per_worker` (default) — enables hot reload during local development.
378
+ # `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
379
+ policy = "per_worker"
380
+ # Port to attach the Chrome inspector for debugging edge functions.
381
+ inspector_port = 8083
382
+ # The Deno major version to use.
383
+ deno_version = 2
384
+
385
+ # [edge_runtime.secrets]
386
+ # secret_key = "env(SECRET_VALUE)"
387
+
388
+ [analytics]
389
+ enabled = true
390
+ port = 54327
391
+ # Configure one of the supported backends: `postgres`, `bigquery`.
392
+ backend = "postgres"
393
+
394
+ # Experimental features may be deprecated any time
395
+ [experimental]
396
+ # Configures Postgres storage engine to use OrioleDB (S3)
397
+ orioledb_version = ""
398
+ # Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
399
+ s3_host = "env(S3_HOST)"
400
+ # Configures S3 bucket region, eg. us-east-1
401
+ s3_region = "env(S3_REGION)"
402
+ # Configures AWS_ACCESS_KEY_ID for S3 bucket
403
+ s3_access_key = "env(S3_ACCESS_KEY)"
404
+ # Configures AWS_SECRET_ACCESS_KEY for S3 bucket
405
+ s3_secret_key = "env(S3_SECRET_KEY)"
406
+
407
+ # [experimental.pgdelta]
408
+ # When enabled, pg-delta becomes the active engine for supported schema flows.
409
+ # enabled = false
410
+ # Directory under `supabase/` where declarative files are written.
411
+ # declarative_schema_path = "./database"
412
+ # JSON string passed through to pg-delta SQL formatting.
413
+ # format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
@@ -0,0 +1,114 @@
1
+ create extension if not exists pgcrypto;
2
+ create extension if not exists pg_trgm;
3
+
4
+ create table if not exists public.people (
5
+ id uuid primary key default gen_random_uuid(),
6
+ canonical_name text not null,
7
+ display_name text,
8
+ sort_name text,
9
+ avatar_url text,
10
+ bio text,
11
+ note text,
12
+ created_at timestamptz not null default now(),
13
+ updated_at timestamptz not null default now()
14
+ );
15
+
16
+ create table if not exists public.person_names (
17
+ id uuid primary key default gen_random_uuid(),
18
+ person_id uuid not null references public.people(id) on delete cascade,
19
+ name text not null,
20
+ name_type text not null default 'alias'
21
+ check (name_type in ('canonical', 'display', 'alias', 'nickname', 'courtesy_name', 'pen_name', 'former_name', 'foreign_name', 'other')),
22
+ locale text,
23
+ is_primary boolean not null default false,
24
+ created_at timestamptz not null default now()
25
+ );
26
+
27
+ create unique index if not exists person_names_unique_lower_name_type
28
+ on public.person_names (person_id, lower(name), name_type);
29
+
30
+ create unique index if not exists person_names_primary_per_type
31
+ on public.person_names (person_id, name_type)
32
+ where is_primary;
33
+
34
+ create index if not exists person_names_name_trgm
35
+ on public.person_names using gin (name gin_trgm_ops);
36
+
37
+ create table if not exists public.person_identifiers (
38
+ id uuid primary key default gen_random_uuid(),
39
+ person_id uuid not null references public.people(id) on delete cascade,
40
+ identifier_type text not null,
41
+ value text not null,
42
+ url text,
43
+ is_primary boolean not null default false,
44
+ created_at timestamptz not null default now(),
45
+ unique (identifier_type, value)
46
+ );
47
+
48
+ create table if not exists public.person_affiliations (
49
+ id uuid primary key default gen_random_uuid(),
50
+ person_id uuid not null references public.people(id) on delete cascade,
51
+ organization_name text not null,
52
+ role_title text,
53
+ start_date date,
54
+ end_date date,
55
+ note text,
56
+ created_at timestamptz not null default now()
57
+ );
58
+
59
+ create table if not exists public.sources (
60
+ id uuid primary key default gen_random_uuid(),
61
+ source_type text not null default 'manual',
62
+ source_ref text,
63
+ title text,
64
+ url text,
65
+ captured_at timestamptz,
66
+ created_at timestamptz not null default now()
67
+ );
68
+
69
+ create table if not exists public.person_facts (
70
+ id uuid primary key default gen_random_uuid(),
71
+ person_id uuid not null references public.people(id) on delete cascade,
72
+ predicate text not null,
73
+ value text not null,
74
+ confidence text not null default 'unverified'
75
+ check (confidence in ('confirmed', 'likely', 'unverified')),
76
+ source_id uuid references public.sources(id) on delete set null,
77
+ valid_from date,
78
+ valid_to date,
79
+ created_at timestamptz not null default now(),
80
+ updated_at timestamptz not null default now()
81
+ );
82
+
83
+ create index if not exists people_canonical_name_trgm
84
+ on public.people using gin (canonical_name gin_trgm_ops);
85
+
86
+ create index if not exists people_display_name_trgm
87
+ on public.people using gin (display_name gin_trgm_ops);
88
+
89
+ create index if not exists person_identifiers_person_id
90
+ on public.person_identifiers (person_id);
91
+
92
+ create index if not exists person_affiliations_person_id
93
+ on public.person_affiliations (person_id);
94
+
95
+ create index if not exists person_facts_person_id_created_at
96
+ on public.person_facts (person_id, created_at desc);
97
+
98
+ create or replace function public.touch_updated_at()
99
+ returns trigger as $$
100
+ begin
101
+ new.updated_at = now();
102
+ return new;
103
+ end;
104
+ $$ language plpgsql;
105
+
106
+ drop trigger if exists touch_people_updated_at on public.people;
107
+ create trigger touch_people_updated_at
108
+ before update on public.people
109
+ for each row execute function public.touch_updated_at();
110
+
111
+ drop trigger if exists touch_person_facts_updated_at on public.person_facts;
112
+ create trigger touch_person_facts_updated_at
113
+ before update on public.person_facts
114
+ for each row execute function public.touch_updated_at();
@@ -0,0 +1,114 @@
1
+ create extension if not exists pgcrypto;
2
+ create extension if not exists pg_trgm;
3
+
4
+ create table if not exists public.people (
5
+ id uuid primary key default gen_random_uuid(),
6
+ canonical_name text not null,
7
+ display_name text,
8
+ sort_name text,
9
+ avatar_url text,
10
+ bio text,
11
+ note text,
12
+ created_at timestamptz not null default now(),
13
+ updated_at timestamptz not null default now()
14
+ );
15
+
16
+ create table if not exists public.person_names (
17
+ id uuid primary key default gen_random_uuid(),
18
+ person_id uuid not null references public.people(id) on delete cascade,
19
+ name text not null,
20
+ name_type text not null default 'alias'
21
+ check (name_type in ('canonical', 'display', 'alias', 'nickname', 'courtesy_name', 'pen_name', 'former_name', 'foreign_name', 'other')),
22
+ locale text,
23
+ is_primary boolean not null default false,
24
+ created_at timestamptz not null default now()
25
+ );
26
+
27
+ create unique index if not exists person_names_unique_lower_name_type
28
+ on public.person_names (person_id, lower(name), name_type);
29
+
30
+ create unique index if not exists person_names_primary_per_type
31
+ on public.person_names (person_id, name_type)
32
+ where is_primary;
33
+
34
+ create index if not exists person_names_name_trgm
35
+ on public.person_names using gin (name gin_trgm_ops);
36
+
37
+ create table if not exists public.person_identifiers (
38
+ id uuid primary key default gen_random_uuid(),
39
+ person_id uuid not null references public.people(id) on delete cascade,
40
+ identifier_type text not null,
41
+ value text not null,
42
+ url text,
43
+ is_primary boolean not null default false,
44
+ created_at timestamptz not null default now(),
45
+ unique (identifier_type, value)
46
+ );
47
+
48
+ create table if not exists public.person_affiliations (
49
+ id uuid primary key default gen_random_uuid(),
50
+ person_id uuid not null references public.people(id) on delete cascade,
51
+ organization_name text not null,
52
+ role_title text,
53
+ start_date date,
54
+ end_date date,
55
+ note text,
56
+ created_at timestamptz not null default now()
57
+ );
58
+
59
+ create table if not exists public.sources (
60
+ id uuid primary key default gen_random_uuid(),
61
+ source_type text not null default 'manual',
62
+ source_ref text,
63
+ title text,
64
+ url text,
65
+ captured_at timestamptz,
66
+ created_at timestamptz not null default now()
67
+ );
68
+
69
+ create table if not exists public.person_facts (
70
+ id uuid primary key default gen_random_uuid(),
71
+ person_id uuid not null references public.people(id) on delete cascade,
72
+ predicate text not null,
73
+ value text not null,
74
+ confidence text not null default 'unverified'
75
+ check (confidence in ('confirmed', 'likely', 'unverified')),
76
+ source_id uuid references public.sources(id) on delete set null,
77
+ valid_from date,
78
+ valid_to date,
79
+ created_at timestamptz not null default now(),
80
+ updated_at timestamptz not null default now()
81
+ );
82
+
83
+ create index if not exists people_canonical_name_trgm
84
+ on public.people using gin (canonical_name gin_trgm_ops);
85
+
86
+ create index if not exists people_display_name_trgm
87
+ on public.people using gin (display_name gin_trgm_ops);
88
+
89
+ create index if not exists person_identifiers_person_id
90
+ on public.person_identifiers (person_id);
91
+
92
+ create index if not exists person_affiliations_person_id
93
+ on public.person_affiliations (person_id);
94
+
95
+ create index if not exists person_facts_person_id_created_at
96
+ on public.person_facts (person_id, created_at desc);
97
+
98
+ create or replace function public.touch_updated_at()
99
+ returns trigger as $$
100
+ begin
101
+ new.updated_at = now();
102
+ return new;
103
+ end;
104
+ $$ language plpgsql;
105
+
106
+ drop trigger if exists touch_people_updated_at on public.people;
107
+ create trigger touch_people_updated_at
108
+ before update on public.people
109
+ for each row execute function public.touch_updated_at();
110
+
111
+ drop trigger if exists touch_person_facts_updated_at on public.person_facts;
112
+ create trigger touch_person_facts_updated_at
113
+ before update on public.person_facts
114
+ for each row execute function public.touch_updated_at();