people-truth 1.0.0 → 1.1.3
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/.env.example +7 -0
- package/README.md +83 -20
- package/dist/cli.js +96 -17
- package/dist/config.d.ts +4 -4
- package/dist/config.js +45 -5
- package/dist/facts.d.ts +3 -11
- package/dist/facts.js +3 -40
- package/dist/people.d.ts +2 -10
- package/dist/people.js +4 -93
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +117 -0
- package/dist/setup.d.ts +19 -0
- package/dist/setup.js +37 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +12 -0
- package/dist/stores/sqlite-store.d.ts +20 -0
- package/dist/stores/sqlite-store.js +172 -0
- package/dist/stores/supabase-store.d.ts +18 -0
- package/dist/stores/supabase-store.js +159 -0
- package/dist/supabase.d.ts +2 -1
- package/dist/supabase.js +5 -3
- package/dist/types.d.ts +41 -0
- package/dist/types.js +1 -0
- package/package.json +7 -2
- package/sample.env +7 -0
- package/skills/people-truth/SKILL.md +12 -4
package/.env.example
ADDED
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# people-truth
|
|
2
2
|
|
|
3
|
-
`people-truth` is a
|
|
3
|
+
`people-truth` is a portable source of truth for stable person identities, names, mentions, affiliations, identifiers, and sourced facts.
|
|
4
|
+
|
|
5
|
+
It can run in two modes:
|
|
6
|
+
|
|
7
|
+
- SQLite: local-first, no cloud dependency, good for personal agents and laptops.
|
|
8
|
+
- Supabase/Postgres: hosted and shareable, good for multi-device or team use.
|
|
4
9
|
|
|
5
10
|
Supabase is treated as a replaceable Postgres runtime. The durable contract is the SQL migration plus the CLI behavior.
|
|
6
11
|
|
|
@@ -15,43 +20,79 @@ For local development:
|
|
|
15
20
|
|
|
16
21
|
```bash
|
|
17
22
|
npm install
|
|
23
|
+
npm test
|
|
18
24
|
npm run build
|
|
19
|
-
npm run dev -- --help
|
|
20
25
|
```
|
|
21
26
|
|
|
22
|
-
##
|
|
27
|
+
## Quick Start: SQLite
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
SQLite is the default provider. This creates `.env`, initializes the local database, and verifies the schema:
|
|
25
30
|
|
|
26
31
|
```bash
|
|
27
|
-
ptruth
|
|
28
|
-
ptruth status
|
|
32
|
+
ptruth setup --provider sqlite
|
|
33
|
+
ptruth db status
|
|
29
34
|
```
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
By default, the database lives at:
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
```text
|
|
39
|
+
~/.people-truth/people-truth.sqlite
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use a custom path when needed:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
ptruth setup --provider sqlite --sqlite-path ./people.sqlite --force
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start: Supabase
|
|
49
|
+
|
|
50
|
+
Create a Supabase project, then configure credentials:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
ptruth setup \
|
|
54
|
+
--provider supabase \
|
|
55
|
+
--url "https://your-project-ref.supabase.co" \
|
|
56
|
+
--key "your-service-role-or-secret-key"
|
|
57
|
+
```
|
|
34
58
|
|
|
35
|
-
|
|
59
|
+
Check whether the required tables exist:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
ptruth db status
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If tables are missing, print the SQL and run it in the Supabase SQL editor:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
ptruth db schema --provider supabase
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
From this repository, you can also link a Supabase project and push migrations:
|
|
36
72
|
|
|
37
73
|
```bash
|
|
38
74
|
npx supabase link --project-ref your-project-ref
|
|
39
75
|
npm run db:push
|
|
40
76
|
```
|
|
41
77
|
|
|
42
|
-
|
|
78
|
+
## Configuration
|
|
43
79
|
|
|
44
|
-
|
|
45
|
-
- `supabase/schema.sql`
|
|
80
|
+
The CLI reads `.env` first, then `~/.codex/.env` when present.
|
|
46
81
|
|
|
47
|
-
|
|
82
|
+
SQLite:
|
|
48
83
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
84
|
+
```env
|
|
85
|
+
PEOPLE_TRUTH_PROVIDER="sqlite"
|
|
86
|
+
PEOPLE_TRUTH_SQLITE_PATH="$HOME/.people-truth/people-truth.sqlite"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Supabase:
|
|
90
|
+
|
|
91
|
+
```env
|
|
92
|
+
PEOPLE_TRUTH_PROVIDER="supabase"
|
|
93
|
+
SUPABASE_URL="https://your-project-ref.supabase.co"
|
|
94
|
+
SUPABASE_KEY="your-service-role-or-secret-key"
|
|
95
|
+
```
|
|
55
96
|
|
|
56
97
|
## CLI
|
|
57
98
|
|
|
@@ -70,6 +111,28 @@ All commands support JSON output:
|
|
|
70
111
|
ptruth people show "梁启超" -o json
|
|
71
112
|
```
|
|
72
113
|
|
|
114
|
+
## Database
|
|
115
|
+
|
|
116
|
+
The schema centers on:
|
|
117
|
+
|
|
118
|
+
- `people`: stable UUID identity.
|
|
119
|
+
- `person_names`: canonical names, aliases, pen names, former names, foreign names.
|
|
120
|
+
- `person_identifiers`: external IDs and URLs.
|
|
121
|
+
- `person_affiliations`: organization and role history.
|
|
122
|
+
- `person_facts`: sourced predicate/value facts.
|
|
123
|
+
- `sources`: provenance.
|
|
124
|
+
|
|
125
|
+
Package SQL assets:
|
|
126
|
+
|
|
127
|
+
- `supabase/migrations/20260611000000_initial_people_truth.sql`
|
|
128
|
+
- `supabase/schema.sql`
|
|
129
|
+
|
|
130
|
+
For SQLite, the equivalent schema is embedded in the CLI and can be printed:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
ptruth db schema --provider sqlite
|
|
134
|
+
```
|
|
135
|
+
|
|
73
136
|
## Mentions
|
|
74
137
|
|
|
75
138
|
Use Markdown-compatible person mentions:
|
|
@@ -90,4 +153,4 @@ fix: handle duplicate person names
|
|
|
90
153
|
feat!: change mention format
|
|
91
154
|
```
|
|
92
155
|
|
|
93
|
-
CI runs typecheck/build/pack. Release publishes to npm when `NPM_TOKEN` is configured in GitHub repository secrets.
|
|
156
|
+
CI runs typecheck/tests/build/pack. Release publishes to npm when `NPM_TOKEN` is configured in GitHub repository secrets.
|
package/dist/cli.js
CHANGED
|
@@ -1,33 +1,80 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { existsSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { resolve } from "node:path";
|
|
5
3
|
import { addFact, listFacts } from "./facts.js";
|
|
6
|
-
import {
|
|
4
|
+
import { defaultSqlitePath, readConfig } from "./config.js";
|
|
7
5
|
import { addPerson, formatPersonMention, getPerson, searchPeople } from "./people.js";
|
|
8
6
|
import { printResult } from "./output.js";
|
|
7
|
+
import { checkDatabase, schemaFor, setupDatabase, writeEnvFile } from "./setup.js";
|
|
9
8
|
const program = new Command();
|
|
10
9
|
program
|
|
11
10
|
.name("people-truth")
|
|
12
|
-
.description("
|
|
11
|
+
.description("Portable source of truth for people, backed by SQLite or Supabase.")
|
|
13
12
|
.option("-o, --output <mode>", "Output mode: table or json", "table");
|
|
14
13
|
program
|
|
15
|
-
.command("
|
|
16
|
-
.description("
|
|
17
|
-
.
|
|
18
|
-
.
|
|
14
|
+
.command("setup")
|
|
15
|
+
.description("Configure people-truth and initialize the selected database")
|
|
16
|
+
.option("--provider <provider>", "sqlite or supabase", "sqlite")
|
|
17
|
+
.option("--url <url>", "Supabase project URL")
|
|
18
|
+
.option("--key <key>", "Supabase service-role or secret key")
|
|
19
|
+
.option("--sqlite-path <path>", "SQLite database path", defaultSqlitePath)
|
|
19
20
|
.option("--path <path>", "Env file path", ".env")
|
|
20
21
|
.option("--force", "Overwrite an existing env file", false)
|
|
22
|
+
.option("--skip-db", "Write config without initializing/checking database", false)
|
|
21
23
|
.action(async (options) => {
|
|
22
24
|
await run(async () => {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const provider = parseProvider(options.provider);
|
|
26
|
+
const envPath = writeEnvFile({
|
|
27
|
+
provider,
|
|
28
|
+
path: options.path,
|
|
29
|
+
force: options.force,
|
|
30
|
+
supabaseUrl: options.url,
|
|
31
|
+
supabaseKey: options.key,
|
|
32
|
+
sqlitePath: options.sqlitePath
|
|
33
|
+
});
|
|
34
|
+
const result = {
|
|
35
|
+
ok: true,
|
|
36
|
+
provider,
|
|
37
|
+
envPath
|
|
38
|
+
};
|
|
39
|
+
if (provider === "sqlite")
|
|
40
|
+
result.sqlitePath = options.sqlitePath;
|
|
41
|
+
if (!options.skipDb) {
|
|
42
|
+
if (provider === "sqlite") {
|
|
43
|
+
result.database = await setupDatabase({ provider, sqlitePath: options.sqlitePath });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
result.database = await checkDatabase({
|
|
47
|
+
provider,
|
|
48
|
+
supabaseUrl: options.url,
|
|
49
|
+
supabaseKey: options.key
|
|
50
|
+
});
|
|
51
|
+
result.nextStep = "If tables are missing, run `ptruth db schema --provider supabase` and apply the SQL in Supabase.";
|
|
52
|
+
}
|
|
26
53
|
}
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
printResult(result, outputMode());
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
program
|
|
58
|
+
.command("init")
|
|
59
|
+
.description("Create a local .env file")
|
|
60
|
+
.option("--provider <provider>", "sqlite or supabase", "supabase")
|
|
61
|
+
.option("--url <url>", "Supabase project URL")
|
|
62
|
+
.option("--key <key>", "Supabase service-role or secret key")
|
|
63
|
+
.option("--sqlite-path <path>", "SQLite database path", defaultSqlitePath)
|
|
64
|
+
.option("--path <path>", "Env file path", ".env")
|
|
65
|
+
.option("--force", "Overwrite an existing env file", false)
|
|
66
|
+
.action(async (options) => {
|
|
67
|
+
await run(async () => {
|
|
68
|
+
const provider = parseProvider(options.provider);
|
|
69
|
+
const envPath = writeEnvFile({
|
|
70
|
+
provider,
|
|
71
|
+
path: options.path,
|
|
72
|
+
force: options.force,
|
|
73
|
+
supabaseUrl: options.url,
|
|
74
|
+
supabaseKey: options.key,
|
|
75
|
+
sqlitePath: options.sqlitePath
|
|
29
76
|
});
|
|
30
|
-
printResult({ ok: true, path: envPath }, outputMode());
|
|
77
|
+
printResult({ ok: true, provider, path: envPath }, outputMode());
|
|
31
78
|
});
|
|
32
79
|
});
|
|
33
80
|
program
|
|
@@ -35,14 +82,41 @@ program
|
|
|
35
82
|
.description("Check local CLI configuration")
|
|
36
83
|
.action(async () => {
|
|
37
84
|
await run(async () => {
|
|
38
|
-
const config =
|
|
85
|
+
const config = readConfig();
|
|
39
86
|
printResult({
|
|
40
87
|
ok: true,
|
|
41
|
-
|
|
42
|
-
|
|
88
|
+
provider: config.provider,
|
|
89
|
+
supabaseUrl: config.supabaseUrl,
|
|
90
|
+
hasSupabaseKey: Boolean(config.supabaseKey),
|
|
91
|
+
sqlitePath: config.sqlitePath
|
|
43
92
|
}, outputMode());
|
|
44
93
|
});
|
|
45
94
|
});
|
|
95
|
+
const db = program.command("db").description("Manage database schema");
|
|
96
|
+
db.command("init")
|
|
97
|
+
.description("Initialize the configured database schema")
|
|
98
|
+
.action(async () => {
|
|
99
|
+
await run(async () => {
|
|
100
|
+
printResult(await setupDatabase(), outputMode());
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
db.command("status")
|
|
104
|
+
.description("Check whether required tables exist")
|
|
105
|
+
.action(async () => {
|
|
106
|
+
await run(async () => {
|
|
107
|
+
printResult(await checkDatabase(), outputMode());
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
db.command("schema")
|
|
111
|
+
.description("Print SQL schema for a provider")
|
|
112
|
+
.option("--provider <provider>", "sqlite or supabase")
|
|
113
|
+
.action(async (options) => {
|
|
114
|
+
await run(async () => {
|
|
115
|
+
const config = readConfig();
|
|
116
|
+
const provider = parseProvider(options.provider ?? config.provider);
|
|
117
|
+
printResult(schemaFor(provider), outputMode());
|
|
118
|
+
});
|
|
119
|
+
});
|
|
46
120
|
const people = program.command("people").description("Manage people");
|
|
47
121
|
people
|
|
48
122
|
.command("add")
|
|
@@ -138,6 +212,11 @@ function outputMode() {
|
|
|
138
212
|
const mode = program.opts().output;
|
|
139
213
|
return mode === "json" ? "json" : "table";
|
|
140
214
|
}
|
|
215
|
+
function parseProvider(value) {
|
|
216
|
+
if (value === "sqlite" || value === "supabase")
|
|
217
|
+
return value;
|
|
218
|
+
throw new Error("Provider must be sqlite or supabase.");
|
|
219
|
+
}
|
|
141
220
|
async function run(fn) {
|
|
142
221
|
try {
|
|
143
222
|
await fn();
|
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import type { RuntimeConfig } from "./types.js";
|
|
2
|
+
export declare const defaultSqlitePath: string;
|
|
3
|
+
export declare function readConfig(): RuntimeConfig;
|
|
4
|
+
export declare function getConfig(): RuntimeConfig;
|
package/dist/config.js
CHANGED
|
@@ -8,14 +8,54 @@ const codexEnvPath = join(homedir(), ".codex", ".env");
|
|
|
8
8
|
if (existsSync(codexEnvPath)) {
|
|
9
9
|
loadDotenv({ path: codexEnvPath, override: false });
|
|
10
10
|
}
|
|
11
|
+
export const defaultSqlitePath = join(homedir(), ".people-truth", "people-truth.sqlite");
|
|
11
12
|
const envSchema = z.object({
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
PEOPLE_TRUTH_PROVIDER: z.enum(["supabase", "sqlite"]).optional(),
|
|
14
|
+
PEOPLE_TRUTH_SQLITE_PATH: z.string().min(1).optional(),
|
|
15
|
+
SUPABASE_URL: z.string().url().optional(),
|
|
16
|
+
SUPABASE_KEY: z.string().min(1).optional()
|
|
14
17
|
});
|
|
15
|
-
export function
|
|
18
|
+
export function readConfig() {
|
|
16
19
|
const parsed = envSchema.safeParse(process.env);
|
|
17
20
|
if (!parsed.success) {
|
|
18
|
-
throw new Error("
|
|
21
|
+
throw new Error("Invalid people-truth environment configuration.");
|
|
22
|
+
}
|
|
23
|
+
const env = parsed.data;
|
|
24
|
+
const provider = resolveProvider(env.PEOPLE_TRUTH_PROVIDER, env);
|
|
25
|
+
return {
|
|
26
|
+
provider,
|
|
27
|
+
supabaseUrl: env.SUPABASE_URL,
|
|
28
|
+
supabaseKey: env.SUPABASE_KEY,
|
|
29
|
+
sqlitePath: expandPath(env.PEOPLE_TRUTH_SQLITE_PATH ?? defaultSqlitePath)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function getConfig() {
|
|
33
|
+
const config = readConfig();
|
|
34
|
+
if (config.provider === "supabase" && (!config.supabaseUrl || !config.supabaseKey)) {
|
|
35
|
+
throw new Error("Missing Supabase config. Run `ptruth setup --provider supabase` or set SUPABASE_URL and SUPABASE_KEY.");
|
|
19
36
|
}
|
|
20
|
-
|
|
37
|
+
if (config.provider === "sqlite" && !config.sqlitePath) {
|
|
38
|
+
throw new Error("Missing SQLite config. Run `ptruth setup --provider sqlite` or set PEOPLE_TRUTH_SQLITE_PATH.");
|
|
39
|
+
}
|
|
40
|
+
return config;
|
|
41
|
+
}
|
|
42
|
+
function resolveProvider(provider, env) {
|
|
43
|
+
if (provider)
|
|
44
|
+
return provider;
|
|
45
|
+
if (env.PEOPLE_TRUTH_SQLITE_PATH)
|
|
46
|
+
return "sqlite";
|
|
47
|
+
if (env.SUPABASE_URL || env.SUPABASE_KEY)
|
|
48
|
+
return "supabase";
|
|
49
|
+
return "sqlite";
|
|
50
|
+
}
|
|
51
|
+
function expandPath(value) {
|
|
52
|
+
if (value === "~")
|
|
53
|
+
return homedir();
|
|
54
|
+
if (value.startsWith("~/"))
|
|
55
|
+
return join(homedir(), value.slice(2));
|
|
56
|
+
if (value === "$HOME")
|
|
57
|
+
return homedir();
|
|
58
|
+
if (value.startsWith("$HOME/"))
|
|
59
|
+
return join(homedir(), value.slice(6));
|
|
60
|
+
return value;
|
|
21
61
|
}
|
package/dist/facts.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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>;
|
|
1
|
+
import type { AddFactInput } from "./types.js";
|
|
2
|
+
export declare function addFact(input: AddFactInput): Promise<unknown>;
|
|
3
|
+
export declare function listFacts(identifier: string): Promise<unknown[]>;
|
package/dist/facts.js
CHANGED
|
@@ -1,44 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSupabase } from "./supabase.js";
|
|
1
|
+
import { getStore } from "./store.js";
|
|
3
2
|
export async function addFact(input) {
|
|
4
|
-
|
|
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;
|
|
3
|
+
return getStore().addFact(input);
|
|
38
4
|
}
|
|
39
5
|
export async function listFacts(identifier) {
|
|
40
|
-
|
|
41
|
-
if (!person)
|
|
42
|
-
throw new Error(`Person not found: ${identifier}`);
|
|
43
|
-
return person.person_facts ?? [];
|
|
6
|
+
return getStore().listFacts(identifier);
|
|
44
7
|
}
|
package/dist/people.d.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
aliases: string[];
|
|
4
|
-
organization?: string;
|
|
5
|
-
role?: string;
|
|
6
|
-
location?: string;
|
|
7
|
-
relationship?: string;
|
|
8
|
-
bio?: string;
|
|
9
|
-
note?: string;
|
|
10
|
-
}): Promise<any>;
|
|
1
|
+
import type { AddPersonInput } from "./types.js";
|
|
2
|
+
export declare function addPerson(input: AddPersonInput): Promise<unknown>;
|
|
11
3
|
export declare function searchPeople(query: string): Promise<unknown[]>;
|
|
12
4
|
export declare function getPerson(identifier: string): Promise<any>;
|
|
13
5
|
export declare function formatPersonMention(identifier: string, label?: string): Promise<string>;
|
package/dist/people.js
CHANGED
|
@@ -1,101 +1,12 @@
|
|
|
1
|
-
import {
|
|
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))";
|
|
1
|
+
import { getStore } from "./store.js";
|
|
3
2
|
export async function addPerson(input) {
|
|
4
|
-
|
|
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);
|
|
3
|
+
return getStore().addPerson(input);
|
|
38
4
|
}
|
|
39
5
|
export async function searchPeople(query) {
|
|
40
|
-
|
|
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);
|
|
6
|
+
return getStore().searchPeople(query);
|
|
67
7
|
}
|
|
68
8
|
export async function getPerson(identifier) {
|
|
69
|
-
|
|
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;
|
|
9
|
+
return getStore().getPerson(identifier);
|
|
99
10
|
}
|
|
100
11
|
export async function formatPersonMention(identifier, label) {
|
|
101
12
|
const person = await getPerson(identifier);
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Provider } from "./types.js";
|
|
2
|
+
export declare const requiredTables: string[];
|
|
3
|
+
export declare function getSchema(provider: Provider): string;
|
|
4
|
+
export declare function getSupabaseSchemaPath(): string;
|
|
5
|
+
export declare const sqliteSchema = "\npragma foreign_keys = on;\n\ncreate table if not exists people (\n id text primary key,\n canonical_name text not null,\n display_name text,\n sort_name text,\n avatar_url text,\n bio text,\n note text,\n created_at text not null default current_timestamp,\n updated_at text not null default current_timestamp\n);\n\ncreate table if not exists person_names (\n id text primary key,\n person_id text not null references people(id) on delete cascade,\n name text not null,\n name_type text not null default 'alias'\n check (name_type in ('canonical', 'display', 'alias', 'nickname', 'courtesy_name', 'pen_name', 'former_name', 'foreign_name', 'other')),\n locale text,\n is_primary integer not null default 0,\n created_at text not null default current_timestamp\n);\n\ncreate unique index if not exists person_names_unique_lower_name_type\n on person_names (person_id, lower(name), name_type);\n\ncreate unique index if not exists person_names_primary_per_type\n on person_names (person_id, name_type)\n where is_primary = 1;\n\ncreate index if not exists person_names_name_idx\n on person_names (name);\n\ncreate table if not exists person_identifiers (\n id text primary key,\n person_id text not null references people(id) on delete cascade,\n identifier_type text not null,\n value text not null,\n url text,\n is_primary integer not null default 0,\n created_at text not null default current_timestamp,\n unique (identifier_type, value)\n);\n\ncreate table if not exists person_affiliations (\n id text primary key,\n person_id text not null references people(id) on delete cascade,\n organization_name text not null,\n role_title text,\n start_date text,\n end_date text,\n note text,\n created_at text not null default current_timestamp\n);\n\ncreate table if not exists sources (\n id text primary key,\n source_type text not null default 'manual',\n source_ref text,\n title text,\n url text,\n captured_at text,\n created_at text not null default current_timestamp\n);\n\ncreate table if not exists person_facts (\n id text primary key,\n person_id text not null references people(id) on delete cascade,\n predicate text not null,\n value text not null,\n confidence text not null default 'unverified'\n check (confidence in ('confirmed', 'likely', 'unverified')),\n source_id text references sources(id) on delete set null,\n valid_from text,\n valid_to text,\n created_at text not null default current_timestamp,\n updated_at text not null default current_timestamp\n);\n\ncreate index if not exists people_canonical_name_idx\n on people (canonical_name);\n\ncreate index if not exists people_display_name_idx\n on people (display_name);\n\ncreate index if not exists person_identifiers_person_id\n on person_identifiers (person_id);\n\ncreate index if not exists person_affiliations_person_id\n on person_affiliations (person_id);\n\ncreate index if not exists person_facts_person_id_created_at\n on person_facts (person_id, created_at desc);\n";
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export const requiredTables = [
|
|
5
|
+
"people",
|
|
6
|
+
"person_names",
|
|
7
|
+
"person_identifiers",
|
|
8
|
+
"person_affiliations",
|
|
9
|
+
"sources",
|
|
10
|
+
"person_facts"
|
|
11
|
+
];
|
|
12
|
+
export function getSchema(provider) {
|
|
13
|
+
if (provider === "sqlite")
|
|
14
|
+
return sqliteSchema;
|
|
15
|
+
return readFileSync(getSupabaseSchemaPath(), "utf8");
|
|
16
|
+
}
|
|
17
|
+
export function getSupabaseSchemaPath() {
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
return join(here, "..", "supabase", "schema.sql");
|
|
20
|
+
}
|
|
21
|
+
export const sqliteSchema = `
|
|
22
|
+
pragma foreign_keys = on;
|
|
23
|
+
|
|
24
|
+
create table if not exists people (
|
|
25
|
+
id text primary key,
|
|
26
|
+
canonical_name text not null,
|
|
27
|
+
display_name text,
|
|
28
|
+
sort_name text,
|
|
29
|
+
avatar_url text,
|
|
30
|
+
bio text,
|
|
31
|
+
note text,
|
|
32
|
+
created_at text not null default current_timestamp,
|
|
33
|
+
updated_at text not null default current_timestamp
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
create table if not exists person_names (
|
|
37
|
+
id text primary key,
|
|
38
|
+
person_id text not null references people(id) on delete cascade,
|
|
39
|
+
name text not null,
|
|
40
|
+
name_type text not null default 'alias'
|
|
41
|
+
check (name_type in ('canonical', 'display', 'alias', 'nickname', 'courtesy_name', 'pen_name', 'former_name', 'foreign_name', 'other')),
|
|
42
|
+
locale text,
|
|
43
|
+
is_primary integer not null default 0,
|
|
44
|
+
created_at text not null default current_timestamp
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
create unique index if not exists person_names_unique_lower_name_type
|
|
48
|
+
on person_names (person_id, lower(name), name_type);
|
|
49
|
+
|
|
50
|
+
create unique index if not exists person_names_primary_per_type
|
|
51
|
+
on person_names (person_id, name_type)
|
|
52
|
+
where is_primary = 1;
|
|
53
|
+
|
|
54
|
+
create index if not exists person_names_name_idx
|
|
55
|
+
on person_names (name);
|
|
56
|
+
|
|
57
|
+
create table if not exists person_identifiers (
|
|
58
|
+
id text primary key,
|
|
59
|
+
person_id text not null references people(id) on delete cascade,
|
|
60
|
+
identifier_type text not null,
|
|
61
|
+
value text not null,
|
|
62
|
+
url text,
|
|
63
|
+
is_primary integer not null default 0,
|
|
64
|
+
created_at text not null default current_timestamp,
|
|
65
|
+
unique (identifier_type, value)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
create table if not exists person_affiliations (
|
|
69
|
+
id text primary key,
|
|
70
|
+
person_id text not null references people(id) on delete cascade,
|
|
71
|
+
organization_name text not null,
|
|
72
|
+
role_title text,
|
|
73
|
+
start_date text,
|
|
74
|
+
end_date text,
|
|
75
|
+
note text,
|
|
76
|
+
created_at text not null default current_timestamp
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
create table if not exists sources (
|
|
80
|
+
id text primary key,
|
|
81
|
+
source_type text not null default 'manual',
|
|
82
|
+
source_ref text,
|
|
83
|
+
title text,
|
|
84
|
+
url text,
|
|
85
|
+
captured_at text,
|
|
86
|
+
created_at text not null default current_timestamp
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
create table if not exists person_facts (
|
|
90
|
+
id text primary key,
|
|
91
|
+
person_id text not null references people(id) on delete cascade,
|
|
92
|
+
predicate text not null,
|
|
93
|
+
value text not null,
|
|
94
|
+
confidence text not null default 'unverified'
|
|
95
|
+
check (confidence in ('confirmed', 'likely', 'unverified')),
|
|
96
|
+
source_id text references sources(id) on delete set null,
|
|
97
|
+
valid_from text,
|
|
98
|
+
valid_to text,
|
|
99
|
+
created_at text not null default current_timestamp,
|
|
100
|
+
updated_at text not null default current_timestamp
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
create index if not exists people_canonical_name_idx
|
|
104
|
+
on people (canonical_name);
|
|
105
|
+
|
|
106
|
+
create index if not exists people_display_name_idx
|
|
107
|
+
on people (display_name);
|
|
108
|
+
|
|
109
|
+
create index if not exists person_identifiers_person_id
|
|
110
|
+
on person_identifiers (person_id);
|
|
111
|
+
|
|
112
|
+
create index if not exists person_affiliations_person_id
|
|
113
|
+
on person_affiliations (person_id);
|
|
114
|
+
|
|
115
|
+
create index if not exists person_facts_person_id_created_at
|
|
116
|
+
on person_facts (person_id, created_at desc);
|
|
117
|
+
`;
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Provider, RuntimeConfig } from "./types.js";
|
|
2
|
+
export interface WriteEnvInput {
|
|
3
|
+
provider: Provider;
|
|
4
|
+
path: string;
|
|
5
|
+
force?: boolean;
|
|
6
|
+
supabaseUrl?: string;
|
|
7
|
+
supabaseKey?: string;
|
|
8
|
+
sqlitePath?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function writeEnvFile(input: WriteEnvInput): string;
|
|
11
|
+
export declare function setupDatabase(config?: RuntimeConfig): Promise<{
|
|
12
|
+
ok: boolean;
|
|
13
|
+
missingTables: string[];
|
|
14
|
+
}>;
|
|
15
|
+
export declare function checkDatabase(config?: RuntimeConfig): Promise<{
|
|
16
|
+
ok: boolean;
|
|
17
|
+
missingTables: string[];
|
|
18
|
+
}>;
|
|
19
|
+
export declare function schemaFor(provider: Provider): string;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { getSchema } from "./schema.js";
|
|
5
|
+
import { getStore } from "./store.js";
|
|
6
|
+
import { defaultSqlitePath, readConfig } from "./config.js";
|
|
7
|
+
export function writeEnvFile(input) {
|
|
8
|
+
const envPath = resolve(input.path);
|
|
9
|
+
if (existsSync(envPath) && !input.force) {
|
|
10
|
+
throw new Error(`${envPath} already exists. Re-run with --force to overwrite it.`);
|
|
11
|
+
}
|
|
12
|
+
const sqlitePath = input.sqlitePath ?? defaultSqlitePath;
|
|
13
|
+
const lines = [`PEOPLE_TRUTH_PROVIDER="${input.provider}"`];
|
|
14
|
+
if (input.provider === "supabase") {
|
|
15
|
+
if (!input.supabaseUrl || !input.supabaseKey) {
|
|
16
|
+
throw new Error("Supabase setup requires --url and --key.");
|
|
17
|
+
}
|
|
18
|
+
lines.push(`SUPABASE_URL="${input.supabaseUrl}"`, `SUPABASE_KEY="${input.supabaseKey}"`);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
lines.push(`PEOPLE_TRUTH_SQLITE_PATH="${sqlitePath}"`);
|
|
22
|
+
mkdirSync(dirname(sqlitePath), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
writeFileSync(envPath, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
25
|
+
return envPath;
|
|
26
|
+
}
|
|
27
|
+
export async function setupDatabase(config = readConfig()) {
|
|
28
|
+
const store = getStore(config);
|
|
29
|
+
await store.initializeSchema();
|
|
30
|
+
return store.checkSchema();
|
|
31
|
+
}
|
|
32
|
+
export async function checkDatabase(config = readConfig()) {
|
|
33
|
+
return getStore(config).checkSchema();
|
|
34
|
+
}
|
|
35
|
+
export function schemaFor(provider) {
|
|
36
|
+
return getSchema(provider);
|
|
37
|
+
}
|
package/dist/store.d.ts
ADDED
package/dist/store.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getConfig } from "./config.js";
|
|
2
|
+
import { getSupabase } from "./supabase.js";
|
|
3
|
+
import { SqliteStore } from "./stores/sqlite-store.js";
|
|
4
|
+
import { SupabaseStore } from "./stores/supabase-store.js";
|
|
5
|
+
export function getStore(config = getConfig()) {
|
|
6
|
+
if (config.provider === "sqlite") {
|
|
7
|
+
if (!config.sqlitePath)
|
|
8
|
+
throw new Error("Missing SQLite database path.");
|
|
9
|
+
return new SqliteStore(config.sqlitePath);
|
|
10
|
+
}
|
|
11
|
+
return new SupabaseStore(getSupabase(config));
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AddFactInput, AddPersonInput, PeopleTruthStore } from "../types.js";
|
|
2
|
+
export declare class SqliteStore implements PeopleTruthStore {
|
|
3
|
+
readonly sqlitePath: string;
|
|
4
|
+
readonly provider = "sqlite";
|
|
5
|
+
private readonly db;
|
|
6
|
+
constructor(sqlitePath: string);
|
|
7
|
+
initializeSchema(): Promise<void>;
|
|
8
|
+
checkSchema(): Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
missingTables: string[];
|
|
11
|
+
}>;
|
|
12
|
+
addPerson(input: AddPersonInput): Promise<any>;
|
|
13
|
+
searchPeople(query: string): Promise<any[]>;
|
|
14
|
+
getPerson(identifier: string): Promise<any>;
|
|
15
|
+
addFact(input: AddFactInput): Promise<any>;
|
|
16
|
+
listFacts(identifier: string): Promise<any>;
|
|
17
|
+
close(): void;
|
|
18
|
+
private getPersonById;
|
|
19
|
+
private getFactById;
|
|
20
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { requiredTables, sqliteSchema } from "../schema.js";
|
|
6
|
+
export class SqliteStore {
|
|
7
|
+
sqlitePath;
|
|
8
|
+
provider = "sqlite";
|
|
9
|
+
db;
|
|
10
|
+
constructor(sqlitePath) {
|
|
11
|
+
this.sqlitePath = sqlitePath;
|
|
12
|
+
mkdirSync(dirname(sqlitePath), { recursive: true });
|
|
13
|
+
this.db = new Database(sqlitePath);
|
|
14
|
+
this.db.pragma("foreign_keys = ON");
|
|
15
|
+
}
|
|
16
|
+
async initializeSchema() {
|
|
17
|
+
this.db.exec(sqliteSchema);
|
|
18
|
+
}
|
|
19
|
+
async checkSchema() {
|
|
20
|
+
const existing = new Set(this.db
|
|
21
|
+
.prepare("select name from sqlite_master where type = 'table'")
|
|
22
|
+
.all()
|
|
23
|
+
.map((row) => row.name));
|
|
24
|
+
const missingTables = requiredTables.filter((table) => !existing.has(table));
|
|
25
|
+
return { ok: missingTables.length === 0, missingTables };
|
|
26
|
+
}
|
|
27
|
+
async addPerson(input) {
|
|
28
|
+
const id = randomUUID();
|
|
29
|
+
const note = input.note ?? ([input.location, input.relationship].filter(Boolean).join("; ") || undefined);
|
|
30
|
+
const createPerson = this.db.transaction(() => {
|
|
31
|
+
this.db
|
|
32
|
+
.prepare("insert into people (id, canonical_name, display_name, bio, note) values (?, ?, ?, ?, ?)")
|
|
33
|
+
.run(id, input.name, input.name, input.bio ?? null, note ?? null);
|
|
34
|
+
const insertName = this.db.prepare("insert into person_names (id, person_id, name, name_type, is_primary) values (?, ?, ?, ?, ?)");
|
|
35
|
+
const names = Array.from(new Set([input.name, ...input.aliases].filter(Boolean)));
|
|
36
|
+
for (const alias of names) {
|
|
37
|
+
insertName.run(randomUUID(), id, alias, alias === input.name ? "canonical" : "alias", alias === input.name ? 1 : 0);
|
|
38
|
+
}
|
|
39
|
+
if (input.organization) {
|
|
40
|
+
this.db
|
|
41
|
+
.prepare("insert into person_affiliations (id, person_id, organization_name, role_title) values (?, ?, ?, ?)")
|
|
42
|
+
.run(randomUUID(), id, input.organization, input.role ?? null);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
createPerson();
|
|
46
|
+
return this.getPerson(id);
|
|
47
|
+
}
|
|
48
|
+
async searchPeople(query) {
|
|
49
|
+
const pattern = `%${query.toLowerCase()}%`;
|
|
50
|
+
const rows = this.db
|
|
51
|
+
.prepare(`
|
|
52
|
+
select distinct p.id
|
|
53
|
+
from people p
|
|
54
|
+
left join person_names pn on pn.person_id = p.id
|
|
55
|
+
where lower(p.canonical_name) like ?
|
|
56
|
+
or lower(coalesce(p.display_name, '')) like ?
|
|
57
|
+
or lower(coalesce(p.bio, '')) like ?
|
|
58
|
+
or lower(coalesce(p.note, '')) like ?
|
|
59
|
+
or lower(coalesce(pn.name, '')) like ?
|
|
60
|
+
order by p.created_at desc
|
|
61
|
+
limit 20
|
|
62
|
+
`)
|
|
63
|
+
.all(pattern, pattern, pattern, pattern, pattern);
|
|
64
|
+
const people = [];
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const person = await this.getPerson(row.id);
|
|
67
|
+
if (person)
|
|
68
|
+
people.push(compactPerson(person));
|
|
69
|
+
}
|
|
70
|
+
return people;
|
|
71
|
+
}
|
|
72
|
+
async getPerson(identifier) {
|
|
73
|
+
const byId = this.getPersonById(identifier);
|
|
74
|
+
if (byId)
|
|
75
|
+
return byId;
|
|
76
|
+
const nameMatches = this.db
|
|
77
|
+
.prepare("select distinct person_id from person_names where lower(name) = lower(?) limit 2")
|
|
78
|
+
.all(identifier);
|
|
79
|
+
if (nameMatches.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
if (nameMatches.length > 1) {
|
|
82
|
+
throw new Error(`Multiple people match "${identifier}". Use a UUID instead.`);
|
|
83
|
+
}
|
|
84
|
+
return this.getPersonById(nameMatches[0].person_id);
|
|
85
|
+
}
|
|
86
|
+
async addFact(input) {
|
|
87
|
+
const person = await this.getPerson(input.person);
|
|
88
|
+
if (!person)
|
|
89
|
+
throw new Error(`Person not found: ${input.person}`);
|
|
90
|
+
const factId = randomUUID();
|
|
91
|
+
const addFact = this.db.transaction(() => {
|
|
92
|
+
let sourceId = null;
|
|
93
|
+
if (input.sourceType || input.sourceRef || input.sourceTitle || input.sourceUrl) {
|
|
94
|
+
sourceId = randomUUID();
|
|
95
|
+
this.db
|
|
96
|
+
.prepare("insert into sources (id, source_type, source_ref, title, url) values (?, ?, ?, ?, ?)")
|
|
97
|
+
.run(sourceId, input.sourceType ?? "manual", input.sourceRef ?? null, input.sourceTitle ?? null, input.sourceUrl ?? null);
|
|
98
|
+
}
|
|
99
|
+
this.db
|
|
100
|
+
.prepare("insert into person_facts (id, person_id, predicate, value, confidence, source_id) values (?, ?, ?, ?, ?, ?)")
|
|
101
|
+
.run(factId, person.id, input.predicate, input.value, input.confidence, sourceId);
|
|
102
|
+
});
|
|
103
|
+
addFact();
|
|
104
|
+
return this.getFactById(factId);
|
|
105
|
+
}
|
|
106
|
+
async listFacts(identifier) {
|
|
107
|
+
const person = await this.getPerson(identifier);
|
|
108
|
+
if (!person)
|
|
109
|
+
throw new Error(`Person not found: ${identifier}`);
|
|
110
|
+
return person.person_facts ?? [];
|
|
111
|
+
}
|
|
112
|
+
close() {
|
|
113
|
+
this.db.close();
|
|
114
|
+
}
|
|
115
|
+
getPersonById(id) {
|
|
116
|
+
const person = this.db.prepare("select * from people where id = ?").get(id);
|
|
117
|
+
if (!person)
|
|
118
|
+
return null;
|
|
119
|
+
person.person_names = this.db
|
|
120
|
+
.prepare("select name, name_type, locale, is_primary from person_names where person_id = ? order by is_primary desc, created_at asc")
|
|
121
|
+
.all(id)
|
|
122
|
+
.map(booleanizePrimary);
|
|
123
|
+
person.person_identifiers = this.db
|
|
124
|
+
.prepare("select identifier_type, value, url, is_primary from person_identifiers where person_id = ? order by is_primary desc, created_at asc")
|
|
125
|
+
.all(id)
|
|
126
|
+
.map(booleanizePrimary);
|
|
127
|
+
person.person_affiliations = this.db
|
|
128
|
+
.prepare("select organization_name, role_title, start_date, end_date, note from person_affiliations where person_id = ? order by created_at desc")
|
|
129
|
+
.all(id);
|
|
130
|
+
person.person_facts = this.db
|
|
131
|
+
.prepare(`
|
|
132
|
+
select pf.*, s.source_type, s.source_ref, s.title as source_title, s.url as source_url
|
|
133
|
+
from person_facts pf
|
|
134
|
+
left join sources s on s.id = pf.source_id
|
|
135
|
+
where pf.person_id = ?
|
|
136
|
+
order by pf.created_at desc
|
|
137
|
+
`)
|
|
138
|
+
.all(id)
|
|
139
|
+
.map(formatFact);
|
|
140
|
+
return person;
|
|
141
|
+
}
|
|
142
|
+
getFactById(id) {
|
|
143
|
+
const row = this.db
|
|
144
|
+
.prepare(`
|
|
145
|
+
select pf.*, s.source_type, s.source_ref, s.title as source_title, s.url as source_url
|
|
146
|
+
from person_facts pf
|
|
147
|
+
left join sources s on s.id = pf.source_id
|
|
148
|
+
where pf.id = ?
|
|
149
|
+
`)
|
|
150
|
+
.get(id);
|
|
151
|
+
return row ? formatFact(row) : null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function booleanizePrimary(row) {
|
|
155
|
+
return { ...row, is_primary: Boolean(row.is_primary) };
|
|
156
|
+
}
|
|
157
|
+
function formatFact(row) {
|
|
158
|
+
const source = row.source_id && row.source_type
|
|
159
|
+
? {
|
|
160
|
+
source_type: row.source_type,
|
|
161
|
+
source_ref: row.source_ref,
|
|
162
|
+
title: row.source_title,
|
|
163
|
+
url: row.source_url
|
|
164
|
+
}
|
|
165
|
+
: null;
|
|
166
|
+
const { source_type, source_ref, source_title, source_url, ...fact } = row;
|
|
167
|
+
return { ...fact, sources: source };
|
|
168
|
+
}
|
|
169
|
+
function compactPerson(person) {
|
|
170
|
+
const { person_identifiers, person_facts, ...rest } = person;
|
|
171
|
+
return rest;
|
|
172
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import type { AddFactInput, AddPersonInput, PeopleTruthStore } from "../types.js";
|
|
3
|
+
export declare class SupabaseStore implements PeopleTruthStore {
|
|
4
|
+
private readonly supabase;
|
|
5
|
+
readonly provider = "supabase";
|
|
6
|
+
constructor(supabase: SupabaseClient);
|
|
7
|
+
initializeSchema(): Promise<void>;
|
|
8
|
+
checkSchema(): Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
missingTables: string[];
|
|
11
|
+
}>;
|
|
12
|
+
addPerson(input: AddPersonInput): Promise<any>;
|
|
13
|
+
searchPeople(query: string): Promise<unknown[]>;
|
|
14
|
+
getPerson(identifier: string): Promise<any>;
|
|
15
|
+
addFact(input: AddFactInput): Promise<any>;
|
|
16
|
+
listFacts(identifier: string): Promise<any>;
|
|
17
|
+
}
|
|
18
|
+
export declare function supabaseSchema(): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { getSchema, requiredTables } from "../schema.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 class SupabaseStore {
|
|
4
|
+
supabase;
|
|
5
|
+
provider = "supabase";
|
|
6
|
+
constructor(supabase) {
|
|
7
|
+
this.supabase = supabase;
|
|
8
|
+
}
|
|
9
|
+
async initializeSchema() {
|
|
10
|
+
throw new Error("Supabase schema cannot be applied through the Data API. Run `ptruth db schema --provider supabase` and apply it in Supabase SQL editor, or use `supabase db push` from the repository.");
|
|
11
|
+
}
|
|
12
|
+
async checkSchema() {
|
|
13
|
+
const missingTables = [];
|
|
14
|
+
for (const table of requiredTables) {
|
|
15
|
+
const { error } = await this.supabase.from(table).select("*", { count: "exact", head: true });
|
|
16
|
+
if (error)
|
|
17
|
+
missingTables.push(table);
|
|
18
|
+
}
|
|
19
|
+
return { ok: missingTables.length === 0, missingTables };
|
|
20
|
+
}
|
|
21
|
+
async addPerson(input) {
|
|
22
|
+
const { data: person, error } = await this.supabase
|
|
23
|
+
.from("people")
|
|
24
|
+
.insert({
|
|
25
|
+
canonical_name: input.name,
|
|
26
|
+
display_name: input.name,
|
|
27
|
+
bio: input.bio,
|
|
28
|
+
note: input.note ?? ([input.location, input.relationship].filter(Boolean).join("; ") || undefined)
|
|
29
|
+
})
|
|
30
|
+
.select()
|
|
31
|
+
.single();
|
|
32
|
+
if (error)
|
|
33
|
+
throw new Error(error.message);
|
|
34
|
+
const names = Array.from(new Set([input.name, ...input.aliases].filter(Boolean)));
|
|
35
|
+
if (names.length > 0) {
|
|
36
|
+
const { error: nameError } = await this.supabase.from("person_names").insert(names.map((alias) => ({
|
|
37
|
+
person_id: person.id,
|
|
38
|
+
name: alias,
|
|
39
|
+
name_type: alias === input.name ? "canonical" : "alias",
|
|
40
|
+
is_primary: alias === input.name
|
|
41
|
+
})));
|
|
42
|
+
if (nameError)
|
|
43
|
+
throw new Error(nameError.message);
|
|
44
|
+
}
|
|
45
|
+
if (input.organization) {
|
|
46
|
+
const { error: affiliationError } = await this.supabase.from("person_affiliations").insert({
|
|
47
|
+
person_id: person.id,
|
|
48
|
+
organization_name: input.organization,
|
|
49
|
+
role_title: input.role
|
|
50
|
+
});
|
|
51
|
+
if (affiliationError)
|
|
52
|
+
throw new Error(affiliationError.message);
|
|
53
|
+
}
|
|
54
|
+
return this.getPerson(person.id);
|
|
55
|
+
}
|
|
56
|
+
async searchPeople(query) {
|
|
57
|
+
const pattern = `%${query}%`;
|
|
58
|
+
const select = "id, canonical_name, display_name, bio, note, person_names(name, name_type), person_affiliations(organization_name, role_title)";
|
|
59
|
+
const { data: directMatches, error } = await this.supabase
|
|
60
|
+
.from("people")
|
|
61
|
+
.select(select)
|
|
62
|
+
.or(`canonical_name.ilike.${pattern},display_name.ilike.${pattern},bio.ilike.${pattern},note.ilike.${pattern}`)
|
|
63
|
+
.limit(20);
|
|
64
|
+
if (error)
|
|
65
|
+
throw new Error(error.message);
|
|
66
|
+
const { data: nameMatches, error: nameError } = await this.supabase
|
|
67
|
+
.from("person_names")
|
|
68
|
+
.select(`people(${select})`)
|
|
69
|
+
.ilike("name", pattern)
|
|
70
|
+
.limit(20);
|
|
71
|
+
if (nameError)
|
|
72
|
+
throw new Error(nameError.message);
|
|
73
|
+
const byId = new Map();
|
|
74
|
+
for (const person of directMatches ?? []) {
|
|
75
|
+
byId.set(person.id, person);
|
|
76
|
+
}
|
|
77
|
+
for (const row of nameMatches ?? []) {
|
|
78
|
+
const person = Array.isArray(row.people) ? row.people[0] : row.people;
|
|
79
|
+
if (person?.id)
|
|
80
|
+
byId.set(person.id, person);
|
|
81
|
+
}
|
|
82
|
+
return Array.from(byId.values()).slice(0, 20);
|
|
83
|
+
}
|
|
84
|
+
async getPerson(identifier) {
|
|
85
|
+
const { data: personById, error: idError } = await this.supabase
|
|
86
|
+
.from("people")
|
|
87
|
+
.select(PERSON_SELECT)
|
|
88
|
+
.eq("id", identifier)
|
|
89
|
+
.maybeSingle();
|
|
90
|
+
if (idError)
|
|
91
|
+
throw new Error(idError.message);
|
|
92
|
+
if (personById)
|
|
93
|
+
return personById;
|
|
94
|
+
const { data: nameMatches, error: aliasError } = await this.supabase
|
|
95
|
+
.from("person_names")
|
|
96
|
+
.select("person_id")
|
|
97
|
+
.ilike("name", identifier)
|
|
98
|
+
.limit(2);
|
|
99
|
+
if (aliasError)
|
|
100
|
+
throw new Error(aliasError.message);
|
|
101
|
+
if (!nameMatches || nameMatches.length === 0)
|
|
102
|
+
return null;
|
|
103
|
+
if (nameMatches.length > 1) {
|
|
104
|
+
throw new Error(`Multiple people match "${identifier}". Use a UUID instead.`);
|
|
105
|
+
}
|
|
106
|
+
const { data, error } = await this.supabase
|
|
107
|
+
.from("people")
|
|
108
|
+
.select(PERSON_SELECT)
|
|
109
|
+
.eq("id", nameMatches[0].person_id)
|
|
110
|
+
.single();
|
|
111
|
+
if (error)
|
|
112
|
+
throw new Error(error.message);
|
|
113
|
+
return data;
|
|
114
|
+
}
|
|
115
|
+
async addFact(input) {
|
|
116
|
+
const person = await this.getPerson(input.person);
|
|
117
|
+
if (!person)
|
|
118
|
+
throw new Error(`Person not found: ${input.person}`);
|
|
119
|
+
let sourceId = null;
|
|
120
|
+
if (input.sourceType || input.sourceRef || input.sourceTitle || input.sourceUrl) {
|
|
121
|
+
const { data: source, error: sourceError } = await this.supabase
|
|
122
|
+
.from("sources")
|
|
123
|
+
.insert({
|
|
124
|
+
source_type: input.sourceType ?? "manual",
|
|
125
|
+
source_ref: input.sourceRef,
|
|
126
|
+
title: input.sourceTitle,
|
|
127
|
+
url: input.sourceUrl
|
|
128
|
+
})
|
|
129
|
+
.select()
|
|
130
|
+
.single();
|
|
131
|
+
if (sourceError)
|
|
132
|
+
throw new Error(sourceError.message);
|
|
133
|
+
sourceId = source.id;
|
|
134
|
+
}
|
|
135
|
+
const { data, error } = await this.supabase
|
|
136
|
+
.from("person_facts")
|
|
137
|
+
.insert({
|
|
138
|
+
person_id: person.id,
|
|
139
|
+
predicate: input.predicate,
|
|
140
|
+
value: input.value,
|
|
141
|
+
confidence: input.confidence,
|
|
142
|
+
source_id: sourceId
|
|
143
|
+
})
|
|
144
|
+
.select("*, sources(source_type, source_ref, title, url)")
|
|
145
|
+
.single();
|
|
146
|
+
if (error)
|
|
147
|
+
throw new Error(error.message);
|
|
148
|
+
return data;
|
|
149
|
+
}
|
|
150
|
+
async listFacts(identifier) {
|
|
151
|
+
const person = await this.getPerson(identifier);
|
|
152
|
+
if (!person)
|
|
153
|
+
throw new Error(`Person not found: ${identifier}`);
|
|
154
|
+
return person.person_facts ?? [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function supabaseSchema() {
|
|
158
|
+
return getSchema("supabase");
|
|
159
|
+
}
|
package/dist/supabase.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { RuntimeConfig } from "./types.js";
|
|
2
|
+
export declare function getSupabase(config?: RuntimeConfig): import("@supabase/supabase-js").SupabaseClient<any, "public", "public", any, any>;
|
package/dist/supabase.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createClient } from "@supabase/supabase-js";
|
|
2
2
|
import { getConfig } from "./config.js";
|
|
3
|
-
export function getSupabase() {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export function getSupabase(config = getConfig()) {
|
|
4
|
+
if (!config.supabaseUrl || !config.supabaseKey) {
|
|
5
|
+
throw new Error("Missing Supabase config. Run `ptruth setup --provider supabase`.");
|
|
6
|
+
}
|
|
7
|
+
return createClient(config.supabaseUrl, config.supabaseKey, {
|
|
6
8
|
auth: {
|
|
7
9
|
persistSession: false,
|
|
8
10
|
autoRefreshToken: false
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type Provider = "supabase" | "sqlite";
|
|
2
|
+
export type Confidence = "confirmed" | "likely" | "unverified";
|
|
3
|
+
export interface RuntimeConfig {
|
|
4
|
+
provider: Provider;
|
|
5
|
+
supabaseUrl?: string;
|
|
6
|
+
supabaseKey?: string;
|
|
7
|
+
sqlitePath?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AddPersonInput {
|
|
10
|
+
name: string;
|
|
11
|
+
aliases: string[];
|
|
12
|
+
organization?: string;
|
|
13
|
+
role?: string;
|
|
14
|
+
location?: string;
|
|
15
|
+
relationship?: string;
|
|
16
|
+
bio?: string;
|
|
17
|
+
note?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface AddFactInput {
|
|
20
|
+
person: string;
|
|
21
|
+
predicate: string;
|
|
22
|
+
value: string;
|
|
23
|
+
confidence: Confidence;
|
|
24
|
+
sourceType?: string;
|
|
25
|
+
sourceRef?: string;
|
|
26
|
+
sourceTitle?: string;
|
|
27
|
+
sourceUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface PeopleTruthStore {
|
|
30
|
+
provider: Provider;
|
|
31
|
+
initializeSchema(): Promise<void>;
|
|
32
|
+
checkSchema(): Promise<{
|
|
33
|
+
ok: boolean;
|
|
34
|
+
missingTables: string[];
|
|
35
|
+
}>;
|
|
36
|
+
addPerson(input: AddPersonInput): Promise<unknown>;
|
|
37
|
+
searchPeople(query: string): Promise<unknown[]>;
|
|
38
|
+
getPerson(identifier: string): Promise<any | null>;
|
|
39
|
+
addFact(input: AddFactInput): Promise<unknown>;
|
|
40
|
+
listFacts(identifier: string): Promise<unknown[]>;
|
|
41
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "people-truth",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "A portable source of truth for people and relationship facts backed by SQLite or Supabase.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"dist",
|
|
21
21
|
"supabase",
|
|
22
22
|
"skills",
|
|
23
|
+
".env.example",
|
|
24
|
+
"sample.env",
|
|
23
25
|
"README.md",
|
|
24
26
|
"LICENSE"
|
|
25
27
|
],
|
|
@@ -35,10 +37,12 @@
|
|
|
35
37
|
"prepack": "npm run build",
|
|
36
38
|
"release": "semantic-release",
|
|
37
39
|
"start": "node dist/cli.js",
|
|
40
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
38
41
|
"typecheck": "tsc --noEmit"
|
|
39
42
|
},
|
|
40
43
|
"dependencies": {
|
|
41
44
|
"@supabase/supabase-js": "^2.108.1",
|
|
45
|
+
"better-sqlite3": "^12.10.0",
|
|
42
46
|
"commander": "^12.1.0",
|
|
43
47
|
"dotenv": "^16.6.1",
|
|
44
48
|
"zod": "^3.25.76"
|
|
@@ -50,6 +54,7 @@
|
|
|
50
54
|
"@semantic-release/github": "^12.0.8",
|
|
51
55
|
"@semantic-release/npm": "^13.1.5",
|
|
52
56
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
57
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
53
58
|
"@types/node": "^22.10.2",
|
|
54
59
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
|
55
60
|
"semantic-release": "^25.0.5",
|
package/sample.env
ADDED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
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.
|
|
3
|
+
description: Use the people-truth CLI and SQLite or 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
4
|
---
|
|
5
5
|
|
|
6
6
|
# People Truth
|
|
7
7
|
|
|
8
8
|
## Core Model
|
|
9
9
|
|
|
10
|
-
Treat Supabase as a replaceable Postgres runtime. The durable contract is the
|
|
10
|
+
Treat SQLite as the local-first default and Supabase as a replaceable hosted Postgres runtime. The durable contract is the schema and CLI behavior in this repository.
|
|
11
11
|
|
|
12
12
|
- `people.id` is the stable UUID identity.
|
|
13
13
|
- `person_names` stores canonical names, display names, aliases, pen names, former names, foreign names, and other human labels.
|
|
@@ -29,7 +29,7 @@ Preserve the display label as written. Resolve the `person:uuid` target when cur
|
|
|
29
29
|
Use `ptruth` when installed globally, or `npm run dev --` inside the repository.
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
ptruth
|
|
32
|
+
ptruth setup --provider sqlite
|
|
33
33
|
ptruth status
|
|
34
34
|
ptruth people add "梁启超" --alias "梁任公" --alias "饮冰室主人"
|
|
35
35
|
ptruth people search "梁任公"
|
|
@@ -39,11 +39,19 @@ ptruth facts add "梁启超" wrote "饮冰室合集" --source manual --confidenc
|
|
|
39
39
|
ptruth facts list "梁启超" -o json
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
For Supabase:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
ptruth setup --provider supabase --url "$SUPABASE_URL" --key "$SUPABASE_KEY"
|
|
46
|
+
ptruth db status
|
|
47
|
+
ptruth db schema --provider supabase
|
|
48
|
+
```
|
|
49
|
+
|
|
42
50
|
When writing automation or agent integrations, prefer `-o json` and parse stdout as JSON.
|
|
43
51
|
|
|
44
52
|
## Migration Workflow
|
|
45
53
|
|
|
46
|
-
Use `supabase/migrations/*.sql` as the source of truth. Keep `supabase/schema.sql` as a readable flattened snapshot of the
|
|
54
|
+
Use `ptruth db init` for SQLite. Use `supabase/migrations/*.sql` as the Supabase source of truth. Keep `supabase/schema.sql` as a readable flattened snapshot of the hosted schema.
|
|
47
55
|
|
|
48
56
|
For local or linked Supabase projects:
|
|
49
57
|
|