@hraness/peopleblade 0.1.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 Hraness
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,79 @@
1
+ # @hraness/peopleblade
2
+
3
+ PeopleBlade is a local-first, source-aware contact graph for people and agents.
4
+ The authoritative database stays in private SQLite on your machine. Cloud sign-in
5
+ and sync are optional.
6
+
7
+ ## Requirements
8
+
9
+ - Bun 1.3.14 or newer
10
+ - macOS or Linux for the currently tested local workflows
11
+ - Provider-specific setup only for live sources you choose to import
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ bun add --global @hraness/peopleblade@0.1.0
17
+ peopleblade --version
18
+ peopleblade init
19
+ peopleblade stats --json
20
+ ```
21
+
22
+ The default database is:
23
+
24
+ - macOS: `~/Library/Application Support/PeopleBlade/peopleblade.sqlite3`
25
+ - Linux: `${XDG_DATA_HOME:-~/.local/share}/peopleblade/peopleblade.sqlite3`
26
+
27
+ Use `peopleblade --help` to list source, identity-review, notes, research, backup,
28
+ and optional cloud commands. Every schema upgrade of an existing database creates
29
+ a private backup before applying migrations. `peopleblade backup` creates an
30
+ additional consistent SQLite backup on demand.
31
+
32
+ Optional cloud credentials are revocable. Use `peopleblade cloud devices` to list
33
+ authorized devices, `peopleblade cloud revoke DEVICE_ID` for a lost device, and
34
+ `peopleblade cloud signout` to revoke the current device before removing its local
35
+ token. The signed-in dashboard provides the lost-device recovery path when no CLI
36
+ token remains available.
37
+
38
+ ### Optional Wrench-backed sources
39
+
40
+ The package does not install Wrench, browser automation, KB, native model runtimes,
41
+ or provider credentials. Beeper, Google, and WhatsApp live sync commands delegate
42
+ through a separately reviewed `wrench` executable on `PATH` (or the absolute
43
+ `PEOPLEBLADE_WRENCH_EXECUTABLE` path). PeopleBlade bundles only the pinned Wrench
44
+ 0.15.0 client validators needed to fence requests and receipts; it does not bundle
45
+ or silently download Wrench's execution runtime.
46
+
47
+ Existing reviewed Wrench 0.15.0 environments remain supported. A fresh public
48
+ Wrench setup is intentionally not prescribed here until Wrench publishes a narrow,
49
+ immutable runtime graph. Archive imports, Apple Contacts, iMessage, notes, identity
50
+ review, backups, and optional PeopleBlade cloud commands do not require Wrench.
51
+
52
+ ## Privacy boundary
53
+
54
+ PeopleBlade's provider import paths never retain, search, enrich from, or upload
55
+ message text. The optional cloud projection excludes source records, raw archives,
56
+ local paths, provider credentials, and message bodies. Ambiguous identity links
57
+ remain reviewable instead of being silently merged.
58
+
59
+ This npm artifact contains only the bundled local CLI, its pinned Wrench client
60
+ validators and executable bridge, SQLite migrations, and package documentation.
61
+ It does not contain the Wrench execution runtime, PeopleBlade web server, billing
62
+ implementation, operational tooling, tests, or private product documentation.
63
+
64
+ ## Update or remove
65
+
66
+ ```bash
67
+ bun update --global @hraness/peopleblade
68
+ bun remove --global @hraness/peopleblade
69
+ ```
70
+
71
+ Removing the package does not delete the local SQLite database or its backups.
72
+ See [peopleblade.com](https://peopleblade.com) for supported sources and current
73
+ product boundaries.
74
+
75
+ ## License
76
+
77
+ The public PeopleBlade CLI package is available under the MIT License. This
78
+ license applies to the npm package, not to PeopleBlade's private repository or
79
+ hosted service.
@@ -0,0 +1,30 @@
1
+ # Third-party notices
2
+
3
+ The PeopleBlade CLI bundle includes reviewed client-boundary code from
4
+ `@hraness/wrench` 0.15.0 (commit
5
+ `41b16fcbf9ed0e8d8a332aa380b7187642aac2e3`) and message-bundle contract code
6
+ from `@hraness/message-like-me` 0.4.0. Those portions are provided under the
7
+ following license. The PeopleBlade CLI package has its own MIT grant in
8
+ `LICENSE`; this notice preserves the attribution for the bundled portions.
9
+
10
+ MIT License
11
+
12
+ Copyright (c) 2026 Hraness contributors
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
package/dist/cli.ts ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { accessSync, constants, realpathSync } from "node:fs";
4
+ import { isAbsolute, resolve } from "node:path";
5
+
6
+ const configured = process.env.PEOPLEBLADE_WRENCH_EXECUTABLE;
7
+ const executable = configured === undefined
8
+ ? Bun.which("wrench")
9
+ : isAbsolute(configured)
10
+ ? resolve(configured)
11
+ : null;
12
+
13
+ if (executable === null) {
14
+ console.error(
15
+ configured === undefined
16
+ ? "PeopleBlade Wrench-backed sources require a separately reviewed `wrench` executable on PATH."
17
+ : "PEOPLEBLADE_WRENCH_EXECUTABLE must be an absolute executable path.",
18
+ );
19
+ process.exit(127);
20
+ }
21
+
22
+ let canonicalExecutable: string;
23
+ try {
24
+ accessSync(executable, constants.X_OK);
25
+ canonicalExecutable = realpathSync(executable);
26
+ } catch {
27
+ console.error("The configured PeopleBlade Wrench executable is unavailable or not executable.");
28
+ process.exit(127);
29
+ }
30
+ if (process.argv[1] !== undefined && canonicalExecutable === realpathSync(process.argv[1])) {
31
+ console.error("The PeopleBlade Wrench executable bridge cannot delegate to itself.");
32
+ process.exit(127);
33
+ }
34
+
35
+ const child = Bun.spawn([canonicalExecutable, ...process.argv.slice(2)], {
36
+ cwd: process.cwd(),
37
+ env: process.env,
38
+ stdin: "inherit",
39
+ stdout: "inherit",
40
+ stderr: "inherit",
41
+ });
42
+
43
+ let terminationTimer: ReturnType<typeof setTimeout> | null = null;
44
+ const forward = (signal: NodeJS.Signals) => {
45
+ try { child.kill(signal); } catch { /* child already settled */ }
46
+ terminationTimer ??= setTimeout(() => {
47
+ try { child.kill("SIGKILL"); } catch { /* child already settled */ }
48
+ }, 5_000);
49
+ terminationTimer.unref();
50
+ };
51
+ const interrupt = () => forward("SIGINT");
52
+ const terminate = () => forward("SIGTERM");
53
+ process.on("SIGINT", interrupt);
54
+ process.on("SIGTERM", terminate);
55
+ try {
56
+ process.exitCode = await child.exited;
57
+ } finally {
58
+ if (terminationTimer !== null) clearTimeout(terminationTimer);
59
+ process.off("SIGINT", interrupt);
60
+ process.off("SIGTERM", terminate);
61
+ }
@@ -0,0 +1,223 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE TABLE local_state (
4
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
5
+ instance_id TEXT NOT NULL UNIQUE CHECK(length(instance_id) = 64 AND instance_id NOT GLOB '*[^0-9a-f]*'),
6
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
7
+ ) STRICT;
8
+ INSERT INTO local_state(singleton, instance_id) VALUES (1, lower(hex(randomblob(32))));
9
+
10
+ CREATE TABLE people (
11
+ id INTEGER PRIMARY KEY,
12
+ stable_key TEXT NOT NULL UNIQUE CHECK(length(stable_key) BETWEEN 3 AND 256),
13
+ display_name TEXT,
14
+ given_name TEXT,
15
+ middle_name TEXT,
16
+ family_name TEXT,
17
+ name_prefix TEXT,
18
+ name_suffix TEXT,
19
+ nickname TEXT,
20
+ organization TEXT,
21
+ department TEXT,
22
+ title TEXT,
23
+ birthday TEXT,
24
+ notes TEXT,
25
+ do_not_contact INTEGER NOT NULL DEFAULT 0 CHECK(do_not_contact IN (0,1)),
26
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
27
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
28
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
29
+ ) STRICT;
30
+ CREATE INDEX people_name ON people(display_name COLLATE NOCASE, id);
31
+
32
+ CREATE TABLE source_runs (
33
+ id INTEGER PRIMARY KEY,
34
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
35
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
36
+ mode TEXT NOT NULL CHECK(length(mode) BETWEEN 1 AND 64),
37
+ source_sha256 TEXT NOT NULL CHECK(length(source_sha256) = 64 AND source_sha256 NOT GLOB '*[^0-9a-f]*'),
38
+ source_locator_sha256 TEXT CHECK(source_locator_sha256 IS NULL OR (length(source_locator_sha256) = 64 AND source_locator_sha256 NOT GLOB '*[^0-9a-f]*')),
39
+ completeness TEXT NOT NULL CHECK(completeness IN ('complete','partial','lower-bound','unknown')),
40
+ result_json TEXT NOT NULL CHECK(json_valid(result_json)),
41
+ started_at TEXT NOT NULL,
42
+ completed_at TEXT,
43
+ UNIQUE(provider, account_key, mode, source_sha256)
44
+ ) STRICT;
45
+
46
+ CREATE TABLE contact_methods (
47
+ id INTEGER PRIMARY KEY,
48
+ person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
49
+ kind TEXT NOT NULL CHECK(kind IN ('email','phone','url','address','date','social','other')),
50
+ value TEXT NOT NULL CHECK(length(value) BETWEEN 1 AND 8192),
51
+ normalized_value TEXT NOT NULL CHECK(length(normalized_value) BETWEEN 1 AND 8192),
52
+ label TEXT NOT NULL DEFAULT '' CHECK(length(label) <= 256),
53
+ is_primary INTEGER NOT NULL DEFAULT 0 CHECK(is_primary IN (0,1)),
54
+ confidence TEXT NOT NULL DEFAULT 'unknown' CHECK(confidence IN ('exact','likely','possible','unknown')),
55
+ first_seen_run_id INTEGER REFERENCES source_runs(id) ON DELETE SET NULL,
56
+ last_seen_run_id INTEGER REFERENCES source_runs(id) ON DELETE SET NULL,
57
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
58
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
59
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
60
+ UNIQUE(person_id, kind, normalized_value, label)
61
+ ) STRICT;
62
+ CREATE INDEX contact_methods_lookup ON contact_methods(kind, normalized_value, person_id);
63
+
64
+ CREATE TABLE provider_resources (
65
+ id INTEGER PRIMARY KEY,
66
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
67
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
68
+ resource_type TEXT NOT NULL CHECK(length(resource_type) BETWEEN 1 AND 64),
69
+ resource_id TEXT NOT NULL CHECK(length(resource_id) BETWEEN 1 AND 4096),
70
+ person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE RESTRICT,
71
+ username TEXT,
72
+ profile_url TEXT,
73
+ display_name TEXT,
74
+ active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
75
+ first_seen_run_id INTEGER REFERENCES source_runs(id) ON DELETE RESTRICT,
76
+ last_seen_run_id INTEGER REFERENCES source_runs(id) ON DELETE RESTRICT,
77
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
78
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
79
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
80
+ UNIQUE(provider, account_key, resource_type, resource_id)
81
+ ) STRICT;
82
+ CREATE INDEX provider_resources_person ON provider_resources(person_id, provider, active);
83
+
84
+ CREATE TABLE source_records (
85
+ run_id INTEGER NOT NULL REFERENCES source_runs(id) ON DELETE RESTRICT,
86
+ ordinal INTEGER NOT NULL CHECK(ordinal > 0),
87
+ record_kind TEXT NOT NULL CHECK(length(record_kind) BETWEEN 1 AND 64),
88
+ record_sha256 TEXT NOT NULL CHECK(length(record_sha256) = 64 AND record_sha256 NOT GLOB '*[^0-9a-f]*'),
89
+ person_id INTEGER REFERENCES people(id) ON DELETE RESTRICT,
90
+ provider_resource_id INTEGER REFERENCES provider_resources(id) ON DELETE RESTRICT,
91
+ resolution TEXT NOT NULL CHECK(resolution IN ('imported','matched','updated','skipped','conflict','error')),
92
+ projection_json TEXT NOT NULL CHECK(json_valid(projection_json)),
93
+ error_text TEXT,
94
+ PRIMARY KEY(run_id, ordinal)
95
+ ) STRICT;
96
+ CREATE INDEX source_records_person ON source_records(person_id, run_id);
97
+
98
+ CREATE TABLE interaction_metrics (
99
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
100
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
101
+ person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
102
+ sent_count INTEGER NOT NULL DEFAULT 0 CHECK(sent_count >= 0),
103
+ received_count INTEGER NOT NULL DEFAULT 0 CHECK(received_count >= 0),
104
+ interaction_count INTEGER NOT NULL DEFAULT 0 CHECK(interaction_count >= 0),
105
+ conversation_count INTEGER CHECK(conversation_count IS NULL OR conversation_count >= 0),
106
+ first_interaction_at TEXT,
107
+ last_interaction_at TEXT,
108
+ reciprocal INTEGER NOT NULL DEFAULT 0 CHECK(reciprocal IN (0,1)),
109
+ completeness TEXT NOT NULL CHECK(completeness IN ('complete','partial','lower-bound','unknown')),
110
+ last_seen_run_id INTEGER REFERENCES source_runs(id) ON DELETE SET NULL,
111
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
112
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
113
+ PRIMARY KEY(provider, account_key, person_id)
114
+ ) STRICT;
115
+ CREATE INDEX interaction_metrics_person ON interaction_metrics(person_id, provider);
116
+
117
+ CREATE TABLE interaction_events (
118
+ id INTEGER PRIMARY KEY,
119
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
120
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
121
+ external_id TEXT NOT NULL CHECK(length(external_id) BETWEEN 1 AND 4096),
122
+ person_id INTEGER REFERENCES people(id) ON DELETE RESTRICT,
123
+ occurred_at TEXT,
124
+ direction TEXT CHECK(direction IN ('incoming','outgoing','unknown')),
125
+ conversation_sha256 TEXT CHECK(conversation_sha256 IS NULL OR (length(conversation_sha256) = 64 AND conversation_sha256 NOT GLOB '*[^0-9a-f]*')),
126
+ event_kind TEXT NOT NULL CHECK(length(event_kind) BETWEEN 1 AND 64),
127
+ resolution TEXT NOT NULL CHECK(resolution IN ('matched','skipped','conflict')),
128
+ run_id INTEGER NOT NULL REFERENCES source_runs(id) ON DELETE RESTRICT,
129
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
130
+ UNIQUE(provider, account_key, external_id)
131
+ ) STRICT;
132
+
133
+ CREATE TABLE tags (
134
+ id INTEGER PRIMARY KEY,
135
+ name TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK(length(name) BETWEEN 1 AND 256)
136
+ ) STRICT;
137
+ CREATE TABLE person_tags (
138
+ person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
139
+ tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
140
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
141
+ PRIMARY KEY(person_id, tag_id)
142
+ ) STRICT;
143
+
144
+ CREATE TABLE collections (
145
+ id INTEGER PRIMARY KEY,
146
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
147
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
148
+ resource_id TEXT NOT NULL CHECK(length(resource_id) BETWEEN 1 AND 2048),
149
+ name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 1024),
150
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
151
+ UNIQUE(provider, account_key, resource_id)
152
+ ) STRICT;
153
+ CREATE TABLE collection_memberships (
154
+ collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
155
+ person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
156
+ state TEXT NOT NULL CHECK(length(state) BETWEEN 1 AND 64),
157
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
158
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
159
+ PRIMARY KEY(collection_id, person_id)
160
+ ) STRICT;
161
+
162
+ CREATE TABLE identity_decisions (
163
+ id INTEGER PRIMARY KEY,
164
+ left_person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE RESTRICT,
165
+ right_person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE RESTRICT,
166
+ action TEXT NOT NULL CHECK(action IN ('accept','reject','defer','separate')),
167
+ evidence_sha256 TEXT NOT NULL CHECK(length(evidence_sha256) = 64 AND evidence_sha256 NOT GLOB '*[^0-9a-f]*'),
168
+ evidence_json TEXT NOT NULL CHECK(json_valid(evidence_json)),
169
+ prior_decision_id INTEGER REFERENCES identity_decisions(id) ON DELETE RESTRICT,
170
+ note TEXT,
171
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
172
+ CHECK(left_person_id < right_person_id)
173
+ ) STRICT;
174
+ CREATE UNIQUE INDEX identity_one_successor ON identity_decisions(prior_decision_id) WHERE prior_decision_id IS NOT NULL;
175
+
176
+ CREATE TABLE public_enrichments (
177
+ id INTEGER PRIMARY KEY,
178
+ person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE RESTRICT,
179
+ provider TEXT NOT NULL CHECK(provider IN ('exa-luna','hunter','agent-public-web','human-public-web')),
180
+ input_sha256 TEXT NOT NULL CHECK(length(input_sha256) = 64 AND input_sha256 NOT GLOB '*[^0-9a-f]*'),
181
+ evidence_sha256 TEXT NOT NULL CHECK(length(evidence_sha256) = 64 AND evidence_sha256 NOT GLOB '*[^0-9a-f]*'),
182
+ evidence_json TEXT NOT NULL CHECK(json_valid(evidence_json)),
183
+ output_json TEXT NOT NULL CHECK(json_valid(output_json)),
184
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
185
+ UNIQUE(person_id, provider, input_sha256, evidence_sha256)
186
+ ) STRICT;
187
+
188
+ CREATE TABLE legacy_migrations (
189
+ id INTEGER PRIMARY KEY,
190
+ source_format TEXT NOT NULL CHECK(source_format = 'rolodex-v1'),
191
+ source_sha256 TEXT NOT NULL UNIQUE CHECK(length(source_sha256) = 64 AND source_sha256 NOT GLOB '*[^0-9a-f]*'),
192
+ source_locator_sha256 TEXT NOT NULL CHECK(length(source_locator_sha256) = 64 AND source_locator_sha256 NOT GLOB '*[^0-9a-f]*'),
193
+ result_json TEXT NOT NULL CHECK(json_valid(result_json)),
194
+ migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
195
+ ) STRICT;
196
+
197
+ CREATE TABLE cloud_sync_state (
198
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
199
+ base_url TEXT NOT NULL,
200
+ device_id TEXT NOT NULL,
201
+ last_snapshot_id TEXT,
202
+ last_synced_at TEXT
203
+ ) STRICT;
204
+
205
+ CREATE TRIGGER people_updated_at AFTER UPDATE ON people FOR EACH ROW
206
+ BEGIN UPDATE people SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END;
207
+ CREATE TRIGGER contact_methods_updated_at AFTER UPDATE ON contact_methods FOR EACH ROW
208
+ BEGIN UPDATE contact_methods SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END;
209
+ CREATE TRIGGER provider_resources_updated_at AFTER UPDATE ON provider_resources FOR EACH ROW
210
+ BEGIN UPDATE provider_resources SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END;
211
+
212
+ CREATE TRIGGER source_runs_immutable_update BEFORE UPDATE ON source_runs FOR EACH ROW
213
+ BEGIN SELECT RAISE(ABORT, 'source runs are immutable'); END;
214
+ CREATE TRIGGER source_runs_immutable_delete BEFORE DELETE ON source_runs FOR EACH ROW
215
+ BEGIN SELECT RAISE(ABORT, 'source runs are immutable'); END;
216
+ CREATE TRIGGER source_records_immutable_update BEFORE UPDATE ON source_records FOR EACH ROW
217
+ BEGIN SELECT RAISE(ABORT, 'source records are immutable'); END;
218
+ CREATE TRIGGER source_records_immutable_delete BEFORE DELETE ON source_records FOR EACH ROW
219
+ BEGIN SELECT RAISE(ABORT, 'source records are immutable'); END;
220
+ CREATE TRIGGER public_enrichments_immutable_update BEFORE UPDATE ON public_enrichments FOR EACH ROW
221
+ BEGIN SELECT RAISE(ABORT, 'public enrichments are immutable'); END;
222
+ CREATE TRIGGER public_enrichments_immutable_delete BEFORE DELETE ON public_enrichments FOR EACH ROW
223
+ BEGIN SELECT RAISE(ABORT, 'public enrichments are immutable'); END;
@@ -0,0 +1,93 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE TABLE local_revisions (
4
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
5
+ identity_revision INTEGER NOT NULL DEFAULT 0 CHECK(identity_revision >= 0)
6
+ ) STRICT;
7
+ INSERT INTO local_revisions(singleton, identity_revision) VALUES (1, 0);
8
+
9
+ CREATE TABLE provider_accounts (
10
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
11
+ auth_id TEXT NOT NULL CHECK(length(auth_id) BETWEEN 1 AND 128),
12
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
13
+ auth_sha256 TEXT NOT NULL CHECK(length(auth_sha256) = 64 AND auth_sha256 NOT GLOB '*[^0-9a-f]*'),
14
+ first_seen_at TEXT NOT NULL,
15
+ last_seen_at TEXT NOT NULL,
16
+ PRIMARY KEY(provider, auth_id),
17
+ UNIQUE(provider, account_key)
18
+ ) STRICT;
19
+
20
+ CREATE TRIGGER provider_accounts_identity_immutable BEFORE UPDATE OF provider,auth_id,account_key,auth_sha256 ON provider_accounts FOR EACH ROW
21
+ BEGIN SELECT RAISE(ABORT, 'provider account binding is immutable'); END;
22
+
23
+ CREATE TABLE archive_accounts (
24
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
25
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
26
+ owner_key TEXT NOT NULL COLLATE NOCASE CHECK(length(owner_key) BETWEEN 1 AND 256),
27
+ current_run_id INTEGER NOT NULL REFERENCES source_runs(id) ON DELETE RESTRICT,
28
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
30
+ PRIMARY KEY(provider, account_key),
31
+ UNIQUE(provider, owner_key)
32
+ ) STRICT;
33
+ CREATE TRIGGER archive_accounts_identity_immutable BEFORE UPDATE OF provider,account_key,owner_key ON archive_accounts FOR EACH ROW
34
+ BEGIN SELECT RAISE(ABORT, 'archive account binding is immutable'); END;
35
+ CREATE TRIGGER archive_accounts_run_insert BEFORE INSERT ON archive_accounts FOR EACH ROW
36
+ BEGIN
37
+ SELECT CASE WHEN NOT EXISTS (
38
+ SELECT 1 FROM source_runs run WHERE run.id=NEW.current_run_id AND run.provider=NEW.provider AND run.account_key=NEW.account_key
39
+ ) THEN RAISE(ABORT, 'archive account run realm mismatch') END;
40
+ END;
41
+ CREATE TRIGGER archive_accounts_run_update BEFORE UPDATE OF current_run_id ON archive_accounts FOR EACH ROW
42
+ BEGIN
43
+ SELECT CASE WHEN NEW.current_run_id <= OLD.current_run_id THEN RAISE(ABORT, 'archive account run must advance') END;
44
+ SELECT CASE WHEN NOT EXISTS (
45
+ SELECT 1 FROM source_runs run WHERE run.id=NEW.current_run_id AND run.provider=NEW.provider AND run.account_key=NEW.account_key
46
+ ) THEN RAISE(ABORT, 'archive account run realm mismatch') END;
47
+ END;
48
+
49
+ CREATE TRIGGER contact_methods_identity_insert AFTER INSERT ON contact_methods FOR EACH ROW
50
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
51
+ CREATE TRIGGER contact_methods_identity_update AFTER UPDATE ON contact_methods FOR EACH ROW
52
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
53
+ CREATE TRIGGER contact_methods_identity_delete AFTER DELETE ON contact_methods FOR EACH ROW
54
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
55
+
56
+ CREATE TABLE provider_checkpoints (
57
+ provider TEXT NOT NULL CHECK(length(provider) BETWEEN 1 AND 64),
58
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
59
+ stream_key TEXT NOT NULL CHECK(length(stream_key) BETWEEN 1 AND 128),
60
+ source_locator_sha256 TEXT NOT NULL CHECK(length(source_locator_sha256) = 64 AND source_locator_sha256 NOT GLOB '*[^0-9a-f]*'),
61
+ schema_sha256 TEXT NOT NULL CHECK(length(schema_sha256) = 64 AND schema_sha256 NOT GLOB '*[^0-9a-f]*'),
62
+ generation_sha256 TEXT CHECK(generation_sha256 IS NULL OR (length(generation_sha256) = 64 AND generation_sha256 NOT GLOB '*[^0-9a-f]*')),
63
+ cursor TEXT NOT NULL CHECK(length(cursor) BETWEEN 1 AND 256),
64
+ cursor_anchor_sha256 TEXT CHECK(cursor_anchor_sha256 IS NULL OR (length(cursor_anchor_sha256) = 64 AND cursor_anchor_sha256 NOT GLOB '*[^0-9a-f]*')),
65
+ last_run_id INTEGER NOT NULL REFERENCES source_runs(id) ON DELETE RESTRICT,
66
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
67
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
68
+ PRIMARY KEY(provider, account_key, stream_key)
69
+ ) STRICT;
70
+
71
+ CREATE TRIGGER provider_checkpoint_run_insert BEFORE INSERT ON provider_checkpoints FOR EACH ROW
72
+ BEGIN
73
+ SELECT CASE WHEN NOT EXISTS (
74
+ SELECT 1 FROM source_runs run
75
+ WHERE run.id = NEW.last_run_id AND run.provider = NEW.provider AND run.account_key = NEW.account_key
76
+ ) THEN RAISE(ABORT, 'checkpoint run realm mismatch') END;
77
+ END;
78
+ CREATE TRIGGER provider_checkpoint_run_update BEFORE UPDATE ON provider_checkpoints FOR EACH ROW
79
+ BEGIN
80
+ SELECT CASE WHEN NEW.provider <> OLD.provider OR NEW.account_key <> OLD.account_key OR NEW.stream_key <> OLD.stream_key
81
+ THEN RAISE(ABORT, 'checkpoint identity is immutable') END;
82
+ SELECT CASE WHEN NEW.last_run_id <= OLD.last_run_id
83
+ THEN RAISE(ABORT, 'checkpoint run must advance') END;
84
+ SELECT CASE WHEN NOT EXISTS (
85
+ SELECT 1 FROM source_runs run
86
+ WHERE run.id = NEW.last_run_id AND run.provider = NEW.provider AND run.account_key = NEW.account_key
87
+ ) THEN RAISE(ABORT, 'checkpoint run realm mismatch') END;
88
+ END;
89
+
90
+ CREATE TRIGGER interaction_events_immutable_update BEFORE UPDATE ON interaction_events FOR EACH ROW
91
+ BEGIN SELECT RAISE(ABORT, 'interaction events are immutable'); END;
92
+ CREATE TRIGGER interaction_events_immutable_delete BEFORE DELETE ON interaction_events FOR EACH ROW
93
+ BEGIN SELECT RAISE(ABORT, 'interaction events are immutable'); END;
@@ -0,0 +1,67 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE VIEW identity_current_decisions AS
4
+ SELECT decision.*
5
+ FROM identity_decisions decision
6
+ WHERE NOT EXISTS (
7
+ SELECT 1 FROM identity_decisions successor WHERE successor.prior_decision_id = decision.id
8
+ );
9
+
10
+ CREATE VIEW person_identity_components AS
11
+ WITH RECURSIVE
12
+ accepted(left_person_id, right_person_id) AS (
13
+ SELECT left_person_id, right_person_id
14
+ FROM identity_current_decisions
15
+ WHERE action = 'accept'
16
+ ),
17
+ reach(person_id, member_id) AS (
18
+ SELECT id, id FROM people
19
+ UNION
20
+ SELECT reach.person_id,
21
+ CASE WHEN accepted.left_person_id = reach.member_id
22
+ THEN accepted.right_person_id ELSE accepted.left_person_id END
23
+ FROM reach
24
+ JOIN accepted
25
+ ON accepted.left_person_id = reach.member_id OR accepted.right_person_id = reach.member_id
26
+ )
27
+ SELECT person_id, min(member_id) AS canonical_person_id
28
+ FROM reach
29
+ GROUP BY person_id;
30
+
31
+ CREATE TRIGGER identity_decisions_immutable_update
32
+ BEFORE UPDATE ON identity_decisions FOR EACH ROW
33
+ BEGIN SELECT RAISE(ABORT, 'identity decisions are immutable'); END;
34
+
35
+ CREATE TRIGGER identity_decisions_immutable_delete
36
+ BEFORE DELETE ON identity_decisions FOR EACH ROW
37
+ BEGIN SELECT RAISE(ABORT, 'identity decisions are immutable'); END;
38
+
39
+ CREATE TRIGGER identity_decisions_predecessor
40
+ BEFORE INSERT ON identity_decisions FOR EACH ROW
41
+ WHEN NEW.prior_decision_id IS NOT NULL
42
+ BEGIN
43
+ SELECT CASE WHEN NOT EXISTS (
44
+ SELECT 1 FROM identity_current_decisions prior
45
+ WHERE prior.id = NEW.prior_decision_id
46
+ AND prior.left_person_id = NEW.left_person_id
47
+ AND prior.right_person_id = NEW.right_person_id
48
+ ) THEN RAISE(ABORT, 'identity predecessor must be the current decision for the same pair') END;
49
+ END;
50
+
51
+ CREATE TRIGGER identity_decisions_revision
52
+ AFTER INSERT ON identity_decisions FOR EACH ROW
53
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
54
+
55
+ CREATE TRIGGER provider_resources_identity_insert
56
+ AFTER INSERT ON provider_resources FOR EACH ROW
57
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
58
+ CREATE TRIGGER provider_resources_identity_update
59
+ AFTER UPDATE OF provider,account_key,resource_type,resource_id,person_id,username,profile_url,active ON provider_resources FOR EACH ROW
60
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
61
+ CREATE TRIGGER provider_resources_identity_delete
62
+ AFTER DELETE ON provider_resources FOR EACH ROW
63
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
64
+
65
+ CREATE TRIGGER people_identity_update
66
+ AFTER UPDATE OF display_name,given_name,middle_name,family_name,name_prefix,name_suffix,nickname,organization,department,title ON people FOR EACH ROW
67
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
@@ -0,0 +1,91 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE TABLE source_bindings (
4
+ id INTEGER PRIMARY KEY,
5
+ authority TEXT NOT NULL CHECK(length(authority) BETWEEN 1 AND 64),
6
+ auth_id TEXT NOT NULL CHECK(length(auth_id) BETWEEN 1 AND 128),
7
+ subject_sha256 TEXT NOT NULL CHECK(length(subject_sha256) = 64 AND subject_sha256 NOT GLOB '*[^0-9a-f]*'),
8
+ auth_sha256 TEXT NOT NULL CHECK(length(auth_sha256) = 64 AND auth_sha256 NOT GLOB '*[^0-9a-f]*'),
9
+ first_seen_at TEXT NOT NULL,
10
+ last_seen_at TEXT NOT NULL,
11
+ complete_realm_inventory_observed_at TEXT,
12
+ complete_realm_inventory_sha256 TEXT,
13
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
14
+ CHECK(
15
+ (complete_realm_inventory_observed_at IS NULL AND complete_realm_inventory_sha256 IS NULL)
16
+ OR (
17
+ complete_realm_inventory_observed_at IS NOT NULL
18
+ AND length(complete_realm_inventory_sha256) = 64
19
+ AND complete_realm_inventory_sha256 NOT GLOB '*[^0-9a-f]*'
20
+ )
21
+ ),
22
+ UNIQUE(authority, auth_id),
23
+ UNIQUE(authority, subject_sha256)
24
+ ) STRICT;
25
+
26
+ CREATE TRIGGER source_bindings_identity_immutable
27
+ BEFORE UPDATE OF authority,auth_id,subject_sha256,auth_sha256 ON source_bindings FOR EACH ROW
28
+ BEGIN SELECT RAISE(ABORT, 'source binding identity is immutable'); END;
29
+
30
+ CREATE TABLE source_realms (
31
+ id INTEGER PRIMARY KEY,
32
+ source_binding_id INTEGER NOT NULL REFERENCES source_bindings(id) ON DELETE RESTRICT,
33
+ authority TEXT NOT NULL CHECK(length(authority) BETWEEN 1 AND 64),
34
+ account_key TEXT NOT NULL COLLATE NOCASE CHECK(length(account_key) BETWEEN 1 AND 256),
35
+ service TEXT NOT NULL CHECK(length(service) BETWEEN 1 AND 64),
36
+ identity_namespace TEXT NOT NULL CHECK(length(identity_namespace) BETWEEN 1 AND 128),
37
+ external_id_sha256 TEXT NOT NULL CHECK(length(external_id_sha256) = 64 AND external_id_sha256 NOT GLOB '*[^0-9a-f]*'),
38
+ active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
39
+ first_seen_at TEXT NOT NULL,
40
+ last_seen_at TEXT NOT NULL,
41
+ state_observed_at TEXT NOT NULL,
42
+ materialized_source_sha256 TEXT CHECK(
43
+ materialized_source_sha256 IS NULL OR (
44
+ length(materialized_source_sha256) = 64
45
+ AND materialized_source_sha256 NOT GLOB '*[^0-9a-f]*'
46
+ )
47
+ ),
48
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
49
+ UNIQUE(authority, account_key),
50
+ UNIQUE(source_binding_id, external_id_sha256)
51
+ ) STRICT;
52
+ CREATE INDEX source_realms_binding ON source_realms(source_binding_id, active, service);
53
+
54
+ CREATE TRIGGER source_realms_identity_immutable
55
+ BEFORE UPDATE OF source_binding_id,authority,account_key,identity_namespace,external_id_sha256
56
+ ON source_realms FOR EACH ROW
57
+ BEGIN SELECT RAISE(ABORT, 'source realm identity is immutable'); END;
58
+
59
+ ALTER TABLE provider_resources ADD COLUMN source_realm_id INTEGER REFERENCES source_realms(id) ON DELETE RESTRICT;
60
+ CREATE INDEX provider_resources_realm ON provider_resources(source_realm_id, active, person_id);
61
+
62
+ ALTER TABLE contact_methods ADD COLUMN provider_resource_id INTEGER REFERENCES provider_resources(id) ON DELETE RESTRICT;
63
+ ALTER TABLE contact_methods ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1));
64
+ ALTER TABLE contact_methods ADD COLUMN identity_eligible INTEGER NOT NULL DEFAULT 1 CHECK(identity_eligible IN (0,1));
65
+ CREATE INDEX contact_methods_resource ON contact_methods(provider_resource_id, active, kind);
66
+
67
+ CREATE TABLE person_field_observations (
68
+ id INTEGER PRIMARY KEY,
69
+ provider_resource_id INTEGER NOT NULL REFERENCES provider_resources(id) ON DELETE RESTRICT,
70
+ field TEXT NOT NULL CHECK(field IN (
71
+ 'display_name','given_name','middle_name','family_name','name_prefix','name_suffix',
72
+ 'nickname','organization','department','title','birthday'
73
+ )),
74
+ value TEXT NOT NULL CHECK(length(value) BETWEEN 1 AND 8192),
75
+ basis TEXT NOT NULL CHECK(length(basis) BETWEEN 1 AND 128),
76
+ priority INTEGER NOT NULL CHECK(priority BETWEEN 0 AND 1000),
77
+ active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
78
+ first_seen_run_id INTEGER NOT NULL REFERENCES source_runs(id) ON DELETE RESTRICT,
79
+ last_seen_run_id INTEGER NOT NULL REFERENCES source_runs(id) ON DELETE RESTRICT,
80
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)),
81
+ UNIQUE(provider_resource_id, field, value, basis)
82
+ ) STRICT;
83
+ CREATE INDEX person_field_observations_current
84
+ ON person_field_observations(provider_resource_id, field, active, priority DESC, id);
85
+
86
+ CREATE TRIGGER person_field_observations_identity_revision_insert
87
+ AFTER INSERT ON person_field_observations FOR EACH ROW
88
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;
89
+ CREATE TRIGGER person_field_observations_identity_revision_update
90
+ AFTER UPDATE OF value,basis,priority,active ON person_field_observations FOR EACH ROW
91
+ BEGIN UPDATE local_revisions SET identity_revision = identity_revision + 1 WHERE singleton = 1; END;