@hraness/peopleblade 0.1.2 → 0.2.1
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/README.md +51 -4
- package/dist/cloud-sync-client.js +13 -4
- package/dist/migrations/017_person_note_content_revisions.sql +151 -0
- package/dist/peopleblade.js +2478 -528
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Installation includes the pinned runtime dependencies Zod 4.4.3 and Effect 3.22.
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
bun add --global @hraness/peopleblade@0.1
|
|
18
|
+
bun add --global @hraness/peopleblade@0.2.1
|
|
19
19
|
peopleblade --version
|
|
20
20
|
peopleblade init
|
|
21
21
|
peopleblade stats --json
|
|
@@ -46,7 +46,7 @@ through a separately reviewed `wrench` executable on `PATH` (or the absolute
|
|
|
46
46
|
0.16.8 client validators needed to fence requests and receipts; it does not bundle
|
|
47
47
|
or silently download Wrench's execution runtime.
|
|
48
48
|
|
|
49
|
-
PeopleBlade 0.1
|
|
49
|
+
PeopleBlade 0.2.1 contains the reviewed Wrench-backed Beeper, Google, and WhatsApp
|
|
50
50
|
live sync commands. Running them requires the separately installed public
|
|
51
51
|
`@hraness/wrench` 0.16.8 package. Beeper support is bounded to account-aware
|
|
52
52
|
`contacts.list@3` reads, `contacts.search@1`, `messaging.search@2`, and the separate
|
|
@@ -65,16 +65,63 @@ Beeper interface directly and exposes none of Wrench's Beeper send or action
|
|
|
65
65
|
operations. Archive imports, Apple Contacts, iMessage, notes, identity review,
|
|
66
66
|
backups, and optional PeopleBlade cloud commands do not require Wrench.
|
|
67
67
|
|
|
68
|
+
## Browse and edit locally
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
peopleblade capabilities --json
|
|
72
|
+
peopleblade query --search "Ada" --sort name --direction asc --json
|
|
73
|
+
peopleblade people show 12 --json
|
|
74
|
+
peopleblade ui
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`capabilities` is offline discovery, not proof that a provider is configured.
|
|
78
|
+
`query` supports source, email, phone and do-not-contact filters, up to 100 rows
|
|
79
|
+
per page, source facets, and `nextOffset` continuation. Pages are a live view;
|
|
80
|
+
imports and identity reviews can move rows between requests. `list` retains its
|
|
81
|
+
legacy shape for existing clients.
|
|
82
|
+
|
|
83
|
+
`ui` starts a local workspace on a random `127.0.0.1` port. Open the printed URL
|
|
84
|
+
and select its owner-only access file. The capability stays in tab memory; the
|
|
85
|
+
page loads no remote assets and performs no provider or cloud calls. Ctrl+C
|
|
86
|
+
stops the workspace and invalidates access. A process running as your OS user
|
|
87
|
+
can access your local files; this is not a sandbox against that process.
|
|
88
|
+
|
|
89
|
+
The workspace supports contact details, note search, Markdown creation/editing,
|
|
90
|
+
safe preview, history and draft recovery. Note edits append immutable user
|
|
91
|
+
revisions; original source content and attribution remain intact. No raw HTML
|
|
92
|
+
or remote images are rendered. Notes never enter cloud sync or agent packets.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
peopleblade notes show 42 --json
|
|
96
|
+
peopleblade notes update 42 --expected-revision 0 \
|
|
97
|
+
--expected-context CONTEXT_SHA256 --request-id REQUEST_UUID \
|
|
98
|
+
--body-file /private/path/note.md --json
|
|
99
|
+
peopleblade notes history 42 --json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Read the revision and context digest from `notes show`. Use a fresh UUID for a
|
|
103
|
+
new edit; retry an uncertain edit with the same UUID and exact input. Stale
|
|
104
|
+
content or identity context fails without overwriting a newer edit. `--body-file -`
|
|
105
|
+
reads bounded UTF-8 stdin. `--title` changes the title, `--clear-title` clears it,
|
|
106
|
+
and omission retains the title from the expected revision.
|
|
107
|
+
|
|
108
|
+
The versioned `research prepare` envelope can be filled and passed directly to
|
|
109
|
+
`research apply`. Keep its subject and instructions unchanged; fill only `result`.
|
|
110
|
+
The envelope binds this database and its current identity evidence. The strict
|
|
111
|
+
legacy three-field input remains supported. JSON-mode errors are one structured
|
|
112
|
+
`peopleblade.error.v1` object on stderr; successful results stay on stdout.
|
|
113
|
+
|
|
68
114
|
## Privacy boundary
|
|
69
115
|
|
|
70
116
|
PeopleBlade's provider import paths never retain, search, enrich from, or upload
|
|
71
117
|
message text. The optional cloud projection excludes source records, raw archives,
|
|
72
|
-
local paths, provider credentials, and message bodies. Ambiguous identity links
|
|
118
|
+
local paths, provider credentials, note bodies and revisions, and message bodies. Ambiguous identity links
|
|
73
119
|
remain reviewable instead of being silently merged.
|
|
74
120
|
|
|
75
121
|
This npm artifact contains only the bundled local CLI, its pinned Wrench client
|
|
76
122
|
validators and executable bridge, SQLite migrations, and package documentation.
|
|
77
|
-
It
|
|
123
|
+
It includes the self-contained loopback workspace, but not the Wrench execution
|
|
124
|
+
runtime, hosted PeopleBlade web server, billing
|
|
78
125
|
implementation, operational tooling, tests, or private product documentation.
|
|
79
126
|
|
|
80
127
|
## Update or remove
|
|
@@ -354,6 +354,7 @@ var serverCliEnrichmentStartSchema = z.object({
|
|
|
354
354
|
var cliEnrichmentStatusSchema = z.object({
|
|
355
355
|
jobId: z.uuid()
|
|
356
356
|
}).strict();
|
|
357
|
+
var cliEnrichmentPublicEmailsRequestSchema = z.object({}).strict();
|
|
357
358
|
var cliEnrichmentRevalidationSchema = z.object({
|
|
358
359
|
limit: z.number().int().min(1).max(100)
|
|
359
360
|
}).strict();
|
|
@@ -1460,7 +1461,14 @@ function localSchemaVersion(database) {
|
|
|
1460
1461
|
throw new Error("PeopleBlade schema is not initialized.");
|
|
1461
1462
|
return row.count;
|
|
1462
1463
|
}
|
|
1463
|
-
function projectCloudContacts(database) {
|
|
1464
|
+
function projectCloudContacts(database, options = {}) {
|
|
1465
|
+
const selected = options.personIds === undefined ? undefined : [...new Set(options.personIds)];
|
|
1466
|
+
if (selected !== undefined && (selected.length > 100 || selected.some((id) => !Number.isSafeInteger(id) || id < 1))) {
|
|
1467
|
+
throw new Error("Projection selection requires at most 100 positive canonical person IDs.");
|
|
1468
|
+
}
|
|
1469
|
+
if (selected?.length === 0)
|
|
1470
|
+
return [];
|
|
1471
|
+
const selectionSql = selected === undefined ? "" : `AND p.id IN (${selected.map(() => "?").join(",")})`;
|
|
1464
1472
|
const rows = database.query(`
|
|
1465
1473
|
WITH
|
|
1466
1474
|
${canonicalInteractionRollupCtesSql},
|
|
@@ -1627,11 +1635,12 @@ function projectCloudContacts(database) {
|
|
|
1627
1635
|
LEFT JOIN source_rollup ON source_rollup.person_id = p.id
|
|
1628
1636
|
LEFT JOIN handle_rollup ON handle_rollup.person_id = p.id
|
|
1629
1637
|
LEFT JOIN resource_eligibility ON resource_eligibility.person_id = p.id
|
|
1630
|
-
WHERE resource_eligibility.person_id IS NULL
|
|
1638
|
+
WHERE (resource_eligibility.person_id IS NULL
|
|
1631
1639
|
OR resource_eligibility.has_active_resource = 1
|
|
1632
|
-
OR canonical_interaction_rollup.person_id IS NOT NULL
|
|
1640
|
+
OR canonical_interaction_rollup.person_id IS NOT NULL)
|
|
1641
|
+
${selectionSql}
|
|
1633
1642
|
ORDER BY p.id
|
|
1634
|
-
`).all();
|
|
1643
|
+
`).all(...selected ?? []);
|
|
1635
1644
|
const instance = localDatabaseFingerprint(database);
|
|
1636
1645
|
return rows.map((row) => {
|
|
1637
1646
|
const sources = sourceLabelSchema.array().parse(parseStringArray(row.sources_json).filter((source) => sourceLabelSchema.safeParse(source).success));
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
PRAGMA foreign_keys = ON;
|
|
2
|
+
|
|
3
|
+
-- User edits are overlays; original provider content and attribution stay immutable.
|
|
4
|
+
CREATE TABLE person_note_content_revisions (
|
|
5
|
+
note_id INTEGER NOT NULL REFERENCES person_notes(id) ON DELETE RESTRICT,
|
|
6
|
+
revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991),
|
|
7
|
+
expected_revision INTEGER NOT NULL CHECK(expected_revision BETWEEN 0 AND 9007199254740990),
|
|
8
|
+
request_id TEXT NOT NULL UNIQUE CHECK(length(request_id) = 36),
|
|
9
|
+
expected_context_sha256 TEXT NOT NULL CHECK(
|
|
10
|
+
length(expected_context_sha256) = 64
|
|
11
|
+
AND expected_context_sha256 NOT GLOB '*[^0-9a-f]*'
|
|
12
|
+
),
|
|
13
|
+
context_json TEXT NOT NULL CHECK(json_valid(context_json)),
|
|
14
|
+
title TEXT CHECK(title IS NULL OR (length(title) BETWEEN 1 AND 1024 AND instr(title, char(0)) = 0)),
|
|
15
|
+
body TEXT NOT NULL CHECK(length(CAST(body AS BLOB)) BETWEEN 1 AND 65536 AND instr(body, char(0)) = 0),
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
17
|
+
PRIMARY KEY(note_id, revision),
|
|
18
|
+
CHECK(revision = expected_revision + 1)
|
|
19
|
+
) STRICT;
|
|
20
|
+
|
|
21
|
+
CREATE TRIGGER person_note_content_revision_valid
|
|
22
|
+
BEFORE INSERT ON person_note_content_revisions FOR EACH ROW
|
|
23
|
+
BEGIN
|
|
24
|
+
SELECT CASE WHEN
|
|
25
|
+
EXISTS (SELECT 1 FROM person_note_content_revisions prior
|
|
26
|
+
WHERE prior.request_id = NEW.request_id
|
|
27
|
+
OR (prior.note_id = NEW.note_id AND prior.revision = NEW.revision))
|
|
28
|
+
OR NEW.expected_revision IS NOT coalesce(
|
|
29
|
+
(SELECT max(prior.revision) FROM person_note_content_revisions prior WHERE prior.note_id = NEW.note_id), 0)
|
|
30
|
+
OR NOT EXISTS (SELECT 1 FROM person_notes WHERE id = NEW.note_id)
|
|
31
|
+
OR json_extract(NEW.context_json, '$.schemaVersion') IS NOT 1
|
|
32
|
+
OR json_extract(NEW.context_json, '$.databaseInstanceId') IS NOT
|
|
33
|
+
(SELECT instance_id FROM local_state WHERE singleton = 1)
|
|
34
|
+
OR json_extract(NEW.context_json, '$.noteId') IS NOT NEW.note_id
|
|
35
|
+
OR json_extract(NEW.context_json, '$.revision') IS NOT NEW.expected_revision
|
|
36
|
+
OR json_extract(NEW.context_json, '$.originalPersonId') IS NOT
|
|
37
|
+
(SELECT person_id FROM person_notes WHERE id = NEW.note_id)
|
|
38
|
+
OR json_extract(NEW.context_json, '$.attributedPersonId') IS NOT
|
|
39
|
+
(SELECT person_id FROM person_notes_current WHERE id = NEW.note_id)
|
|
40
|
+
OR json_extract(NEW.context_json, '$.attributionRevisionId') IS NOT coalesce(
|
|
41
|
+
(SELECT max(id) FROM person_note_attribution_revisions WHERE note_id = NEW.note_id), 0)
|
|
42
|
+
OR json_extract(NEW.context_json, '$.identityRevision') IS NOT
|
|
43
|
+
(SELECT identity_revision FROM local_revisions WHERE singleton = 1)
|
|
44
|
+
OR json_extract(NEW.context_json, '$.canonicalPersonId') IS NOT
|
|
45
|
+
(SELECT component.canonical_person_id FROM person_identity_components component
|
|
46
|
+
JOIN person_notes_current note ON note.person_id = component.person_id WHERE note.id = NEW.note_id)
|
|
47
|
+
OR json_extract(NEW.context_json, '$.componentPersonIds') IS NOT (
|
|
48
|
+
SELECT json_group_array(person_id) FROM (
|
|
49
|
+
SELECT member.person_id FROM person_identity_components member
|
|
50
|
+
WHERE member.canonical_person_id = (
|
|
51
|
+
SELECT component.canonical_person_id FROM person_identity_components component
|
|
52
|
+
JOIN person_notes_current note ON note.person_id = component.person_id WHERE note.id = NEW.note_id
|
|
53
|
+
) ORDER BY member.person_id
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
THEN RAISE(ABORT, 'person note revision context is not current') END;
|
|
57
|
+
END;
|
|
58
|
+
|
|
59
|
+
CREATE TABLE person_note_creation_requests (
|
|
60
|
+
request_id TEXT PRIMARY KEY CHECK(length(request_id) = 36),
|
|
61
|
+
note_id INTEGER NOT NULL UNIQUE REFERENCES person_notes(id) ON DELETE RESTRICT,
|
|
62
|
+
requested_person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE RESTRICT,
|
|
63
|
+
expected_context_sha256 TEXT NOT NULL CHECK(
|
|
64
|
+
length(expected_context_sha256) = 64 AND expected_context_sha256 NOT GLOB '*[^0-9a-f]*'
|
|
65
|
+
),
|
|
66
|
+
context_json TEXT NOT NULL CHECK(json_valid(context_json)),
|
|
67
|
+
occurred_at_input TEXT NOT NULL CHECK(length(occurred_at_input) BETWEEN 1 AND 80)
|
|
68
|
+
) STRICT;
|
|
69
|
+
|
|
70
|
+
CREATE TRIGGER person_note_creation_request_valid
|
|
71
|
+
BEFORE INSERT ON person_note_creation_requests FOR EACH ROW
|
|
72
|
+
BEGIN
|
|
73
|
+
SELECT CASE WHEN
|
|
74
|
+
EXISTS (SELECT 1 FROM person_note_creation_requests prior
|
|
75
|
+
WHERE prior.request_id = NEW.request_id OR prior.note_id = NEW.note_id)
|
|
76
|
+
OR json_extract(NEW.context_json, '$.schemaVersion') IS NOT 1
|
|
77
|
+
OR json_extract(NEW.context_json, '$.databaseInstanceId') IS NOT
|
|
78
|
+
(SELECT instance_id FROM local_state WHERE singleton = 1)
|
|
79
|
+
OR json_extract(NEW.context_json, '$.personId') IS NOT NEW.requested_person_id
|
|
80
|
+
OR json_extract(NEW.context_json, '$.identityRevision') IS NOT
|
|
81
|
+
(SELECT identity_revision FROM local_revisions WHERE singleton = 1)
|
|
82
|
+
OR json_extract(NEW.context_json, '$.canonicalPersonId') IS NOT
|
|
83
|
+
(SELECT canonical_person_id FROM person_identity_components WHERE person_id = NEW.requested_person_id)
|
|
84
|
+
OR json_extract(NEW.context_json, '$.componentPersonIds') IS NOT (
|
|
85
|
+
SELECT json_group_array(person_id) FROM (
|
|
86
|
+
SELECT member.person_id FROM person_identity_components member
|
|
87
|
+
WHERE member.canonical_person_id = (
|
|
88
|
+
SELECT canonical_person_id FROM person_identity_components WHERE person_id = NEW.requested_person_id
|
|
89
|
+
) ORDER BY member.person_id
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
OR NOT EXISTS (SELECT 1 FROM person_notes note
|
|
93
|
+
WHERE note.id = NEW.note_id AND note.source = 'manual' AND note.source_id = NEW.request_id
|
|
94
|
+
AND note.person_id = json_extract(NEW.context_json, '$.canonicalPersonId'))
|
|
95
|
+
THEN RAISE(ABORT, 'person note creation context is not current') END;
|
|
96
|
+
END;
|
|
97
|
+
|
|
98
|
+
CREATE TRIGGER person_note_creation_requests_immutable_update
|
|
99
|
+
BEFORE UPDATE ON person_note_creation_requests FOR EACH ROW
|
|
100
|
+
BEGIN SELECT RAISE(ABORT, 'person note creation requests are immutable'); END;
|
|
101
|
+
CREATE TRIGGER person_note_creation_requests_immutable_delete
|
|
102
|
+
BEFORE DELETE ON person_note_creation_requests FOR EACH ROW
|
|
103
|
+
BEGIN SELECT RAISE(ABORT, 'person note creation requests are immutable'); END;
|
|
104
|
+
|
|
105
|
+
CREATE TRIGGER person_note_content_revisions_immutable_update
|
|
106
|
+
BEFORE UPDATE ON person_note_content_revisions FOR EACH ROW
|
|
107
|
+
BEGIN SELECT RAISE(ABORT, 'person note content revisions are immutable'); END;
|
|
108
|
+
CREATE TRIGGER person_note_content_revisions_immutable_delete
|
|
109
|
+
BEFORE DELETE ON person_note_content_revisions FOR EACH ROW
|
|
110
|
+
BEGIN SELECT RAISE(ABORT, 'person note content revisions are immutable'); END;
|
|
111
|
+
|
|
112
|
+
CREATE VIEW person_notes_effective AS
|
|
113
|
+
SELECT note.id, note.person_id, note.occurred_at, note.source, note.source_id,
|
|
114
|
+
CASE WHEN revision.revision IS NULL THEN note.title ELSE revision.title END AS title,
|
|
115
|
+
coalesce(revision.body, note.body) AS body,
|
|
116
|
+
note.metadata_json, note.created_at,
|
|
117
|
+
coalesce(revision.revision, 0) AS revision,
|
|
118
|
+
revision.created_at AS revised_at
|
|
119
|
+
FROM person_notes_current note
|
|
120
|
+
LEFT JOIN person_note_content_revisions revision ON revision.note_id = note.id
|
|
121
|
+
AND revision.revision = (
|
|
122
|
+
SELECT max(latest.revision) FROM person_note_content_revisions latest WHERE latest.note_id = note.id
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
CREATE VIRTUAL TABLE person_notes_effective_fts USING fts5(
|
|
126
|
+
title, body, content='person_notes_effective', content_rowid='id',
|
|
127
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
128
|
+
);
|
|
129
|
+
INSERT INTO person_notes_effective_fts(rowid, title, body)
|
|
130
|
+
SELECT id, coalesce(title, ''), body FROM person_notes_effective;
|
|
131
|
+
|
|
132
|
+
CREATE TRIGGER person_notes_effective_fts_original_insert
|
|
133
|
+
AFTER INSERT ON person_notes FOR EACH ROW
|
|
134
|
+
BEGIN
|
|
135
|
+
INSERT INTO person_notes_effective_fts(rowid, title, body)
|
|
136
|
+
VALUES (NEW.id, coalesce(NEW.title, ''), NEW.body);
|
|
137
|
+
END;
|
|
138
|
+
|
|
139
|
+
CREATE TRIGGER person_notes_effective_fts_revision_insert
|
|
140
|
+
AFTER INSERT ON person_note_content_revisions FOR EACH ROW
|
|
141
|
+
BEGIN
|
|
142
|
+
INSERT INTO person_notes_effective_fts(person_notes_effective_fts, rowid, title, body)
|
|
143
|
+
SELECT 'delete', NEW.note_id, coalesce(note.title, ''), note.body
|
|
144
|
+
FROM person_notes note WHERE note.id = NEW.note_id AND NEW.expected_revision = 0;
|
|
145
|
+
INSERT INTO person_notes_effective_fts(person_notes_effective_fts, rowid, title, body)
|
|
146
|
+
SELECT 'delete', NEW.note_id, coalesce(prior.title, ''), prior.body
|
|
147
|
+
FROM person_note_content_revisions prior
|
|
148
|
+
WHERE prior.note_id = NEW.note_id AND prior.revision = NEW.expected_revision;
|
|
149
|
+
INSERT INTO person_notes_effective_fts(rowid, title, body)
|
|
150
|
+
VALUES (NEW.note_id, coalesce(NEW.title, ''), NEW.body);
|
|
151
|
+
END;
|