fuli-context 0.2.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 +201 -0
- package/README.md +178 -0
- package/compose.graphiti.yml +86 -0
- package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
- package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
- package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
- package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
- package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
- package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
- package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
- package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
- package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
- package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
- package/dist/web/assets/console-tbvTUi6p.js +1 -0
- package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
- package/dist/web/assets/identity-B3s5CXhv.js +1 -0
- package/dist/web/assets/index-Devf7glZ.css +1 -0
- package/dist/web/assets/index-DwUDhQKc.js +6 -0
- package/dist/web/assets/model-DVT8sjy9.js +1 -0
- package/dist/web/assets/paths-B790iniO.js +1 -0
- package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
- package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
- package/dist/web/index.html +22 -0
- package/graph-provider/Dockerfile +16 -0
- package/graph-provider/fuli_graph/__init__.py +5 -0
- package/graph-provider/fuli_graph/app.py +547 -0
- package/graph-provider/fuli_graph/auth.py +17 -0
- package/graph-provider/fuli_graph/collaboration_context.py +290 -0
- package/graph-provider/fuli_graph/config.py +34 -0
- package/graph-provider/fuli_graph/graph_models.py +123 -0
- package/graph-provider/fuli_graph/graph_projection.py +405 -0
- package/graph-provider/fuli_graph/graph_query.py +561 -0
- package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
- package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
- package/graph-provider/fuli_graph/knowledge_management.py +816 -0
- package/graph-provider/fuli_graph/knowledge_records.py +65 -0
- package/graph-provider/fuli_graph/knowledge_search.py +723 -0
- package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
- package/graph-provider/fuli_graph/model_validation.py +58 -0
- package/graph-provider/fuli_graph/models.py +1179 -0
- package/graph-provider/fuli_graph/personal_project_access.py +29 -0
- package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
- package/graph-provider/fuli_graph/project_action_models.py +142 -0
- package/graph-provider/fuli_graph/project_knowledge.py +707 -0
- package/graph-provider/fuli_graph/project_management.py +251 -0
- package/graph-provider/fuli_graph/provider_values.py +40 -0
- package/graph-provider/fuli_graph/runtime.py +350 -0
- package/graph-provider/fuli_graph/search_projection.py +156 -0
- package/graph-provider/fuli_graph/store.py +24 -0
- package/graph-provider/fuli_graph/store_identity.py +237 -0
- package/graph-provider/fuli_graph/store_knowledge.py +447 -0
- package/graph-provider/fuli_graph/store_projects.py +242 -0
- package/graph-provider/fuli_graph/store_publication.py +370 -0
- package/graph-provider/fuli_graph/store_records.py +134 -0
- package/graph-provider/pyproject.toml +35 -0
- package/npm-shrinkwrap.json +6045 -0
- package/package.json +99 -0
- package/skills/capturing-session-knowledge/SKILL.md +226 -0
- package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
- package/skills/grilling-project/SKILL.md +129 -0
- package/skills/grilling-project/agents/openai.yaml +4 -0
- package/skills/grilling-project/references/project-profile.md +105 -0
- package/src/agent/candidate-read-service.js +53 -0
- package/src/agent/context-pack-service.js +83 -0
- package/src/agent/public-read-policy.js +39 -0
- package/src/agent/public-read-service.js +69 -0
- package/src/agent/read-projection.js +107 -0
- package/src/agent/search-service.js +61 -0
- package/src/agent/space-boundary.js +33 -0
- package/src/agent-tools/graph-definitions.js +479 -0
- package/src/agent-tools/graph-handlers.js +96 -0
- package/src/agent-tools/schema.js +37 -0
- package/src/agent-tools.js +16 -0
- package/src/app/application-error.js +13 -0
- package/src/app/bootstrap.js +8 -0
- package/src/app/create-application.js +143 -0
- package/src/app/state-service.js +38 -0
- package/src/candidates.js +78 -0
- package/src/classifier.js +63 -0
- package/src/cli/command-arguments.js +14 -0
- package/src/cli/command-registry.js +78 -0
- package/src/cli/commands/candidates.js +17 -0
- package/src/cli/commands/ingestion.js +32 -0
- package/src/cli/commands/reads.js +50 -0
- package/src/cli/commands/spaces.js +20 -0
- package/src/cli/invocation.js +47 -0
- package/src/cli/local-runtime-command.js +94 -0
- package/src/cli/local-runtime-options.js +64 -0
- package/src/cli/migrate-command.js +71 -0
- package/src/cli/migration-destination.js +117 -0
- package/src/cli/setup-agent-selection.js +195 -0
- package/src/cli/setup-command.js +85 -0
- package/src/cli/uninstall-command.js +67 -0
- package/src/cli.js +90 -0
- package/src/context-pack.js +206 -0
- package/src/defaults.js +1 -0
- package/src/extractor.js +77 -0
- package/src/fact-history.js +39 -0
- package/src/facts/fact-service.js +89 -0
- package/src/graphiti/agent-access-policy.js +88 -0
- package/src/graphiti/capture-policy.js +84 -0
- package/src/graphiti/federated-application.js +1292 -0
- package/src/graphiti/personal-context.js +29 -0
- package/src/graphiti/project-path-context.js +159 -0
- package/src/graphiti/provider-client.js +288 -0
- package/src/graphiti/runtime-config.js +85 -0
- package/src/graphiti/setup.js +43 -0
- package/src/graphiti/source-marker.js +142 -0
- package/src/http/api-router.js +133 -0
- package/src/http/bounded-json-array.js +19 -0
- package/src/http/error-mapping.js +36 -0
- package/src/http/graph-api-router.js +257 -0
- package/src/http/lens-route.js +50 -0
- package/src/http/request-policy.js +41 -0
- package/src/http/response.js +29 -0
- package/src/http/static-handler.js +55 -0
- package/src/ingestion.js +161 -0
- package/src/lens/lens-correction-service.js +81 -0
- package/src/lens/lens-fact-writer.js +85 -0
- package/src/lens/lens-input.js +62 -0
- package/src/lens/lens-query-projection.js +87 -0
- package/src/lens/lens-query-ranking.js +62 -0
- package/src/lens/lens-query.js +143 -0
- package/src/lens/lens-resource-projection.js +93 -0
- package/src/lens/lens-resource-selection.js +45 -0
- package/src/lens/lens-resource-service.js +103 -0
- package/src/lens/lens-search-query.js +122 -0
- package/src/lens/lens-service.js +183 -0
- package/src/local-runtime/lifecycle.js +254 -0
- package/src/mcp/create-mcp-server.js +85 -0
- package/src/mcp/instructions.js +10 -0
- package/src/mcp/project-action-preview-tokens.js +72 -0
- package/src/mcp/runtime.js +71 -0
- package/src/mcp/tool-annotations.js +63 -0
- package/src/mcp/tool-result.js +167 -0
- package/src/mcp/tool-schema.js +60 -0
- package/src/mcp-server.js +76 -0
- package/src/models.js +44 -0
- package/src/observer.js +83 -0
- package/src/package-metadata.js +6 -0
- package/src/project-rules.js +33 -0
- package/src/publication/canonical-json.js +39 -0
- package/src/publication/outbox-service.js +46 -0
- package/src/publication/publication-envelope-schema.js +75 -0
- package/src/publication/publication-envelope.js +37 -0
- package/src/publication/publication-policy.js +82 -0
- package/src/publication/publication-service.js +48 -0
- package/src/router.js +58 -0
- package/src/runtime-options.js +139 -0
- package/src/security/episode-visibility.js +14 -0
- package/src/security/fact-visibility.js +15 -0
- package/src/security/sensitive-content.js +18 -0
- package/src/security/source-metadata-policy.js +8 -0
- package/src/server/application-lifecycle.js +34 -0
- package/src/server/listen.js +24 -0
- package/src/server.js +135 -0
- package/src/setup/agent-installation-status.js +97 -0
- package/src/setup/agents.js +123 -0
- package/src/setup/codex-bootstrap.js +118 -0
- package/src/setup/codex-config.js +122 -0
- package/src/setup/config-backup.js +22 -0
- package/src/setup/container-runtime.js +388 -0
- package/src/setup/cursor-config.js +61 -0
- package/src/setup/graph-runtime.js +360 -0
- package/src/setup/node-runtime.js +24 -0
- package/src/setup/options.js +91 -0
- package/src/setup/paths.js +47 -0
- package/src/setup/session-skill.js +91 -0
- package/src/setup/setup.js +85 -0
- package/src/setup/uninstall.js +90 -0
- package/src/storage/episode-evidence-preview.js +100 -0
- package/src/storage/fact-search-order.js +18 -0
- package/src/storage/file/episode-evidence.js +37 -0
- package/src/storage/file/episode-index.js +59 -0
- package/src/storage/file/fact-search.js +60 -0
- package/src/storage/file-store.js +437 -0
- package/src/storage/import-json.js +70 -0
- package/src/storage/json-file.js +41 -0
- package/src/storage/legacy-record-validation.js +152 -0
- package/src/storage/legacy-snapshot-validation.js +32 -0
- package/src/storage/migrate.js +128 -0
- package/src/storage/migrations/001-initial.sql +83 -0
- package/src/storage/migrations/002-storage-integrity.sql +113 -0
- package/src/storage/record-validation.js +46 -0
- package/src/storage/snapshot-defaults.js +54 -0
- package/src/storage/sqlite/candidates-repository.js +84 -0
- package/src/storage/sqlite/episode-evidence-repository.js +154 -0
- package/src/storage/sqlite/episode-privacy-sql.js +33 -0
- package/src/storage/sqlite/episodes-repository.js +52 -0
- package/src/storage/sqlite/fact-search-repository.js +162 -0
- package/src/storage/sqlite/facts-repository.js +146 -0
- package/src/storage/sqlite/imports-repository.js +39 -0
- package/src/storage/sqlite/mapper.js +84 -0
- package/src/storage/sqlite/open-database.js +15 -0
- package/src/storage/sqlite/outbox-repository.js +103 -0
- package/src/storage/sqlite/snapshot-repository.js +69 -0
- package/src/storage/sqlite/spaces-repository.js +52 -0
- package/src/storage/sqlite/subscriptions-repository.js +54 -0
- package/src/storage/sqlite-store.js +159 -0
- package/src/storage/stable-json.js +24 -0
- package/src/storage/store-port.js +20 -0
- package/src/store.js +2 -0
- package/src/workspace-protocol/array-validation.js +19 -0
- package/src/workspace-protocol/base64url-contract.js +27 -0
- package/src/workspace-protocol/constants.js +37 -0
- package/src/workspace-protocol/error-contract.js +24 -0
- package/src/workspace-protocol/fact-contract.js +110 -0
- package/src/workspace-protocol/governance-contract.js +47 -0
- package/src/workspace-protocol/index.js +53 -0
- package/src/workspace-protocol/json-budget.js +19 -0
- package/src/workspace-protocol/manifest-contract.js +186 -0
- package/src/workspace-protocol/publication-contract.js +212 -0
- package/src/workspace-protocol/query-contract.js +149 -0
- package/src/workspace-protocol/scalar-contract.js +192 -0
- package/src/workspace-protocol/sync-contract.js +184 -0
- package/src/workspace-protocol/text-contract.js +39 -0
- package/src/workspace-protocol/validation.js +66 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { validateLegacyRecords } from './legacy-record-validation.js';
|
|
2
|
+
|
|
3
|
+
const REQUIRED_COLLECTIONS = ['spaces', 'subscriptions', 'episodes', 'facts', 'candidates'];
|
|
4
|
+
const OPTIONAL_COLLECTIONS = ['outbox', 'imports'];
|
|
5
|
+
|
|
6
|
+
export function validateLegacySnapshot(snapshot) {
|
|
7
|
+
if (!isRecord(snapshot)) throw new TypeError('Snapshot must be a JSON object');
|
|
8
|
+
const collections = requireCollections(snapshot);
|
|
9
|
+
validateLegacyRecords(collections);
|
|
10
|
+
return collections;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function requireCollections(snapshot) {
|
|
14
|
+
const collections = {};
|
|
15
|
+
for (const name of REQUIRED_COLLECTIONS) collections[name] = requireArray(snapshot, name);
|
|
16
|
+
for (const name of OPTIONAL_COLLECTIONS) {
|
|
17
|
+
if (Object.hasOwn(snapshot, name)) collections[name] = requireArray(snapshot, name);
|
|
18
|
+
}
|
|
19
|
+
return collections;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireArray(snapshot, name) {
|
|
23
|
+
if (!Array.isArray(snapshot[name])) throw new TypeError(`Snapshot ${name} must be an array`);
|
|
24
|
+
return snapshot[name].map((record, index) => {
|
|
25
|
+
if (!isRecord(record)) throw new TypeError(`Snapshot ${name}[${index}] must be an object`);
|
|
26
|
+
return record;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { nowIso } from '../models.js';
|
|
6
|
+
import { isFileDatabase } from './sqlite/open-database.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MIGRATIONS_DIRECTORY = new URL('./migrations/', import.meta.url);
|
|
9
|
+
const CHECKSUM_UPGRADE_MIGRATION = '002-storage-integrity.sql';
|
|
10
|
+
|
|
11
|
+
export function configureDatabase(db, filePath) {
|
|
12
|
+
db.pragma('foreign_keys = ON');
|
|
13
|
+
db.pragma('busy_timeout = 5000');
|
|
14
|
+
if (isFileDatabase(filePath)) db.pragma('journal_mode = WAL');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function runMigrations(
|
|
18
|
+
db,
|
|
19
|
+
{ migrationsDirectory = DEFAULT_MIGRATIONS_DIRECTORY } = {}
|
|
20
|
+
) {
|
|
21
|
+
db.pragma('foreign_keys = ON');
|
|
22
|
+
const migrations = discoverMigrations(migrationsDirectory);
|
|
23
|
+
for (const migration of migrations) {
|
|
24
|
+
const migrate = db.transaction(() => {
|
|
25
|
+
const state = appliedMigrations(db);
|
|
26
|
+
verifyAppliedMigrations(migrations, state.records, {
|
|
27
|
+
allowMissingChecksums: hasPendingChecksumUpgrade(migrations, state)
|
|
28
|
+
});
|
|
29
|
+
if (state.records.has(migration.version)) return;
|
|
30
|
+
db.exec(migration.sql);
|
|
31
|
+
recordMigration(db, migration);
|
|
32
|
+
});
|
|
33
|
+
migrate.immediate();
|
|
34
|
+
}
|
|
35
|
+
verifyAppliedMigrations(migrations, appliedMigrations(db).records);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function discoverMigrations(directory) {
|
|
39
|
+
const migrations = readdirSync(directory)
|
|
40
|
+
.filter((name) => name.endsWith('.sql'))
|
|
41
|
+
.map((name) => {
|
|
42
|
+
const match = /^(\d+)-.+\.sql$/.exec(name);
|
|
43
|
+
if (!match) throw new Error(`Invalid migration filename: ${name}`);
|
|
44
|
+
const sql = readFileSync(migrationPath(directory, name), 'utf8');
|
|
45
|
+
return {
|
|
46
|
+
version: Number.parseInt(match[1], 10),
|
|
47
|
+
name,
|
|
48
|
+
sql,
|
|
49
|
+
checksum: createHash('sha256').update(sql).digest('hex')
|
|
50
|
+
};
|
|
51
|
+
})
|
|
52
|
+
.sort((left, right) => left.version - right.version || left.name.localeCompare(right.name));
|
|
53
|
+
|
|
54
|
+
for (let index = 1; index < migrations.length; index += 1) {
|
|
55
|
+
if (migrations[index - 1].version === migrations[index].version) {
|
|
56
|
+
throw new Error(`Duplicate migration version: ${migrations[index].version}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return migrations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function appliedMigrations(db) {
|
|
63
|
+
const columns = db.prepare('PRAGMA table_info(schema_migrations)').all();
|
|
64
|
+
if (columns.length === 0) return { records: new Map(), supportsChecksums: false };
|
|
65
|
+
|
|
66
|
+
const supportsChecksums = columns.some((column) => column.name === 'checksum');
|
|
67
|
+
const rows = supportsChecksums
|
|
68
|
+
? db.prepare(`
|
|
69
|
+
SELECT version, checksum FROM schema_migrations ORDER BY version
|
|
70
|
+
`).all()
|
|
71
|
+
: db.prepare(`
|
|
72
|
+
SELECT version, NULL AS checksum FROM schema_migrations ORDER BY version
|
|
73
|
+
`).all();
|
|
74
|
+
return {
|
|
75
|
+
records: new Map(rows.map((row) => [row.version, row.checksum])),
|
|
76
|
+
supportsChecksums
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function verifyAppliedMigrations(
|
|
81
|
+
migrations,
|
|
82
|
+
applied,
|
|
83
|
+
{ allowMissingChecksums = false } = {}
|
|
84
|
+
) {
|
|
85
|
+
const available = new Map(migrations.map((migration) => [migration.version, migration]));
|
|
86
|
+
for (const [version, checksum] of applied) {
|
|
87
|
+
const migration = available.get(version);
|
|
88
|
+
if (!migration) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Applied migration version ${version} is missing from the migration directory`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (checksum === null && allowMissingChecksums) continue;
|
|
94
|
+
if (checksum === null) {
|
|
95
|
+
throw new Error(`Applied migration version ${version} is missing a checksum`);
|
|
96
|
+
}
|
|
97
|
+
if (migration.checksum !== checksum) {
|
|
98
|
+
throw new Error(`Migration checksum mismatch for version ${version}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasPendingChecksumUpgrade(migrations, state) {
|
|
104
|
+
if (state.supportsChecksums) return false;
|
|
105
|
+
const migration = migrations.find(
|
|
106
|
+
(candidate) => candidate.name === CHECKSUM_UPGRADE_MIGRATION
|
|
107
|
+
);
|
|
108
|
+
return migration !== undefined && !state.records.has(migration.version);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function recordMigration(db, migration) {
|
|
112
|
+
const supportsChecksums = db.prepare('PRAGMA table_info(schema_migrations)').all()
|
|
113
|
+
.some((column) => column.name === 'checksum');
|
|
114
|
+
if (supportsChecksums) {
|
|
115
|
+
db.prepare(`
|
|
116
|
+
INSERT INTO schema_migrations (version, checksum, applied_at)
|
|
117
|
+
VALUES (?, ?, ?)
|
|
118
|
+
`).run(migration.version, migration.checksum, nowIso());
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
db.prepare(`
|
|
122
|
+
INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)
|
|
123
|
+
`).run(migration.version, nowIso());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function migrationPath(directory, name) {
|
|
127
|
+
return directory instanceof URL ? new URL(name, directory) : join(directory, name);
|
|
128
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
PRAGMA foreign_keys = ON;
|
|
2
|
+
|
|
3
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
4
|
+
version INTEGER PRIMARY KEY,
|
|
5
|
+
applied_at TEXT NOT NULL
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
CREATE TABLE spaces (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL UNIQUE,
|
|
11
|
+
kind TEXT NOT NULL CHECK (kind IN ('personal', 'public')),
|
|
12
|
+
description TEXT,
|
|
13
|
+
created_at TEXT NOT NULL
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE subscriptions (
|
|
17
|
+
personal_space_id TEXT NOT NULL REFERENCES spaces(id),
|
|
18
|
+
space_id TEXT NOT NULL REFERENCES spaces(id),
|
|
19
|
+
mode TEXT NOT NULL DEFAULT 'latest',
|
|
20
|
+
created_at TEXT NOT NULL,
|
|
21
|
+
PRIMARY KEY (personal_space_id, space_id)
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE episodes (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
space_id TEXT NOT NULL REFERENCES spaces(id),
|
|
27
|
+
source_kind TEXT NOT NULL,
|
|
28
|
+
body TEXT NOT NULL,
|
|
29
|
+
source_uri TEXT,
|
|
30
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
31
|
+
created_at TEXT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE facts (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
space_id TEXT NOT NULL REFERENCES spaces(id),
|
|
37
|
+
subject TEXT NOT NULL,
|
|
38
|
+
predicate TEXT NOT NULL,
|
|
39
|
+
object TEXT NOT NULL,
|
|
40
|
+
source_episode_id TEXT NOT NULL REFERENCES episodes(id),
|
|
41
|
+
status TEXT NOT NULL,
|
|
42
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
43
|
+
sensitivity TEXT NOT NULL DEFAULT 'normal',
|
|
44
|
+
scope TEXT NOT NULL DEFAULT 'personal',
|
|
45
|
+
valid_at TEXT NOT NULL,
|
|
46
|
+
invalid_at TEXT,
|
|
47
|
+
replaced_by_fact_id TEXT REFERENCES facts(id)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX facts_current_idx ON facts(space_id, predicate, invalid_at);
|
|
51
|
+
CREATE INDEX facts_source_idx ON facts(source_episode_id);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE candidates (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
personal_space_id TEXT NOT NULL REFERENCES spaces(id),
|
|
56
|
+
target_space_id TEXT REFERENCES spaces(id),
|
|
57
|
+
episode_id TEXT NOT NULL REFERENCES episodes(id),
|
|
58
|
+
reason TEXT NOT NULL,
|
|
59
|
+
status TEXT NOT NULL,
|
|
60
|
+
created_at TEXT NOT NULL,
|
|
61
|
+
decided_at TEXT
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE outbox (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
kind TEXT NOT NULL,
|
|
67
|
+
aggregate_id TEXT NOT NULL,
|
|
68
|
+
payload_json TEXT NOT NULL,
|
|
69
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
70
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
71
|
+
next_attempt_at TEXT,
|
|
72
|
+
created_at TEXT NOT NULL,
|
|
73
|
+
sent_at TEXT,
|
|
74
|
+
last_error TEXT
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE INDEX outbox_pending_idx ON outbox(status, next_attempt_at, created_at);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE imports (
|
|
80
|
+
content_hash TEXT PRIMARY KEY,
|
|
81
|
+
source_path TEXT NOT NULL,
|
|
82
|
+
imported_at TEXT NOT NULL
|
|
83
|
+
);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
CREATE TABLE schema_migrations_new (
|
|
2
|
+
version INTEGER PRIMARY KEY,
|
|
3
|
+
checksum TEXT NOT NULL,
|
|
4
|
+
applied_at TEXT NOT NULL
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
INSERT INTO schema_migrations_new (version, checksum, applied_at)
|
|
8
|
+
SELECT
|
|
9
|
+
version,
|
|
10
|
+
CASE version
|
|
11
|
+
WHEN 1 THEN 'ab393d736d8ff18fde75478002b2b4b3b4d7e906974cd724058e31436fba0ea9'
|
|
12
|
+
END,
|
|
13
|
+
applied_at
|
|
14
|
+
FROM schema_migrations;
|
|
15
|
+
|
|
16
|
+
DROP TABLE schema_migrations;
|
|
17
|
+
ALTER TABLE schema_migrations_new RENAME TO schema_migrations;
|
|
18
|
+
|
|
19
|
+
CREATE TABLE facts_new (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
space_id TEXT NOT NULL REFERENCES spaces(id),
|
|
22
|
+
subject TEXT NOT NULL,
|
|
23
|
+
predicate TEXT NOT NULL,
|
|
24
|
+
object TEXT NOT NULL,
|
|
25
|
+
source_episode_id TEXT NOT NULL REFERENCES episodes(id),
|
|
26
|
+
status TEXT NOT NULL CHECK (
|
|
27
|
+
status IN ('confirmed', 'observed', 'suggested', 'rejected', 'deprecated')
|
|
28
|
+
),
|
|
29
|
+
confidence REAL NOT NULL DEFAULT 1.0 CHECK (
|
|
30
|
+
typeof(confidence) IN ('integer', 'real') AND confidence >= 0.0 AND confidence <= 1.0
|
|
31
|
+
),
|
|
32
|
+
sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK (
|
|
33
|
+
sensitivity IN ('normal', 'private', 'restricted')
|
|
34
|
+
),
|
|
35
|
+
scope TEXT NOT NULL DEFAULT 'personal' CHECK (scope IN ('personal', 'public')),
|
|
36
|
+
valid_at TEXT NOT NULL,
|
|
37
|
+
invalid_at TEXT,
|
|
38
|
+
replaced_by_fact_id TEXT REFERENCES facts_new(id)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
INSERT INTO facts_new (
|
|
42
|
+
id, space_id, subject, predicate, object, source_episode_id, status,
|
|
43
|
+
confidence, sensitivity, scope, valid_at, invalid_at, replaced_by_fact_id
|
|
44
|
+
)
|
|
45
|
+
SELECT
|
|
46
|
+
id, space_id, subject, predicate, object, source_episode_id, status,
|
|
47
|
+
confidence, sensitivity, scope, valid_at, invalid_at, NULL
|
|
48
|
+
FROM facts;
|
|
49
|
+
|
|
50
|
+
UPDATE facts_new
|
|
51
|
+
SET replaced_by_fact_id = (
|
|
52
|
+
SELECT facts.replaced_by_fact_id
|
|
53
|
+
FROM facts
|
|
54
|
+
WHERE facts.id = facts_new.id
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
DROP TABLE facts;
|
|
58
|
+
ALTER TABLE facts_new RENAME TO facts;
|
|
59
|
+
CREATE INDEX facts_current_idx ON facts(space_id, predicate, invalid_at);
|
|
60
|
+
CREATE INDEX facts_source_idx ON facts(source_episode_id);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE candidates_new (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
personal_space_id TEXT NOT NULL REFERENCES spaces(id),
|
|
65
|
+
target_space_id TEXT REFERENCES spaces(id),
|
|
66
|
+
episode_id TEXT NOT NULL REFERENCES episodes(id),
|
|
67
|
+
reason TEXT NOT NULL,
|
|
68
|
+
status TEXT NOT NULL CHECK (
|
|
69
|
+
status IN ('pending', 'synced', 'personal_only', 'ignored')
|
|
70
|
+
),
|
|
71
|
+
created_at TEXT NOT NULL,
|
|
72
|
+
decided_at TEXT
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
INSERT INTO candidates_new (
|
|
76
|
+
id, personal_space_id, target_space_id, episode_id, reason,
|
|
77
|
+
status, created_at, decided_at
|
|
78
|
+
)
|
|
79
|
+
SELECT
|
|
80
|
+
id, personal_space_id, target_space_id, episode_id, reason,
|
|
81
|
+
status, created_at, decided_at
|
|
82
|
+
FROM candidates;
|
|
83
|
+
|
|
84
|
+
DROP TABLE candidates;
|
|
85
|
+
ALTER TABLE candidates_new RENAME TO candidates;
|
|
86
|
+
|
|
87
|
+
CREATE TABLE outbox_new (
|
|
88
|
+
id TEXT PRIMARY KEY,
|
|
89
|
+
kind TEXT NOT NULL,
|
|
90
|
+
aggregate_id TEXT NOT NULL,
|
|
91
|
+
payload_json TEXT NOT NULL,
|
|
92
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent')),
|
|
93
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK (
|
|
94
|
+
typeof(attempts) = 'integer' AND attempts >= 0
|
|
95
|
+
),
|
|
96
|
+
next_attempt_at TEXT,
|
|
97
|
+
created_at TEXT NOT NULL,
|
|
98
|
+
sent_at TEXT,
|
|
99
|
+
last_error TEXT
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
INSERT INTO outbox_new (
|
|
103
|
+
id, kind, aggregate_id, payload_json, status, attempts,
|
|
104
|
+
next_attempt_at, created_at, sent_at, last_error
|
|
105
|
+
)
|
|
106
|
+
SELECT
|
|
107
|
+
id, kind, aggregate_id, payload_json, status, attempts,
|
|
108
|
+
next_attempt_at, created_at, sent_at, last_error
|
|
109
|
+
FROM outbox;
|
|
110
|
+
|
|
111
|
+
DROP TABLE outbox;
|
|
112
|
+
ALTER TABLE outbox_new RENAME TO outbox;
|
|
113
|
+
CREATE INDEX outbox_pending_idx ON outbox(status, next_attempt_at, created_at);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CandidateStatus, FactScope, FactStatus, Sensitivity } from '../models.js';
|
|
2
|
+
|
|
3
|
+
const FACT_STATUSES = new Set(Object.values(FactStatus));
|
|
4
|
+
const CANDIDATE_STATUSES = new Set(Object.values(CandidateStatus));
|
|
5
|
+
const SENSITIVITIES = new Set(Object.values(Sensitivity));
|
|
6
|
+
const SCOPES = new Set(Object.values(FactScope));
|
|
7
|
+
const OUTBOX_STATUSES = new Set(['pending', 'sent']);
|
|
8
|
+
|
|
9
|
+
export function validateFactRecord(fact) {
|
|
10
|
+
requireMember('fact status', fact.status, FACT_STATUSES);
|
|
11
|
+
if (
|
|
12
|
+
typeof fact.confidence !== 'number' ||
|
|
13
|
+
!Number.isFinite(fact.confidence) ||
|
|
14
|
+
fact.confidence < 0 ||
|
|
15
|
+
fact.confidence > 1
|
|
16
|
+
) {
|
|
17
|
+
throw new TypeError('Fact confidence must be between 0 and 1');
|
|
18
|
+
}
|
|
19
|
+
requireMember('fact sensitivity', fact.sensitivity, SENSITIVITIES);
|
|
20
|
+
requireMember('fact scope', fact.scope, SCOPES);
|
|
21
|
+
return fact;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateCandidateRecord(candidate) {
|
|
25
|
+
requireMember('candidate status', candidate.status, CANDIDATE_STATUSES);
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateOutboxRecord(entry) {
|
|
30
|
+
requireMember('outbox status', entry.status, OUTBOX_STATUSES);
|
|
31
|
+
if (!Number.isInteger(entry.attempts) || entry.attempts < 0) {
|
|
32
|
+
throw new TypeError('Outbox attempts must be a nonnegative integer');
|
|
33
|
+
}
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateSnapshotRecords(snapshot) {
|
|
38
|
+
for (const fact of snapshot.facts) validateFactRecord(fact);
|
|
39
|
+
for (const candidate of snapshot.candidates) validateCandidateRecord(candidate);
|
|
40
|
+
for (const entry of snapshot.outbox) validateOutboxRecord(entry);
|
|
41
|
+
return snapshot;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireMember(label, value, allowed) {
|
|
45
|
+
if (!allowed.has(value)) throw new TypeError(`Invalid ${label}: ${value}`);
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CandidateStatus, FactStatus, nowIso } from '../models.js';
|
|
2
|
+
import { validateSnapshotRecords } from './record-validation.js';
|
|
3
|
+
|
|
4
|
+
export function normalizeSnapshot(snapshot = {}, { outbox = [], imports = [] } = {}) {
|
|
5
|
+
const input = cloneSnapshot(snapshot);
|
|
6
|
+
const normalized = {
|
|
7
|
+
spaces: (input.spaces ?? []).map((space) => ({
|
|
8
|
+
...space,
|
|
9
|
+
description: space.description ?? null,
|
|
10
|
+
createdAt: space.createdAt ?? nowIso()
|
|
11
|
+
})),
|
|
12
|
+
subscriptions: (input.subscriptions ?? []).map((subscription) => ({
|
|
13
|
+
...subscription,
|
|
14
|
+
mode: subscription.mode ?? 'latest',
|
|
15
|
+
createdAt: subscription.createdAt ?? nowIso()
|
|
16
|
+
})),
|
|
17
|
+
episodes: (input.episodes ?? []).map((episode) => ({
|
|
18
|
+
...episode,
|
|
19
|
+
sourceUri: episode.sourceUri ?? null,
|
|
20
|
+
metadata: episode.metadata ?? {},
|
|
21
|
+
createdAt: episode.createdAt ?? nowIso()
|
|
22
|
+
})),
|
|
23
|
+
facts: (input.facts ?? []).map((fact) => ({
|
|
24
|
+
...fact,
|
|
25
|
+
status: fact.status ?? FactStatus.CONFIRMED,
|
|
26
|
+
confidence: fact.confidence ?? 1,
|
|
27
|
+
sensitivity: fact.sensitivity ?? 'normal',
|
|
28
|
+
scope: fact.scope ?? 'personal',
|
|
29
|
+
validAt: fact.validAt ?? nowIso(),
|
|
30
|
+
invalidAt: fact.invalidAt ?? null,
|
|
31
|
+
replacedByFactId: fact.replacedByFactId ?? null
|
|
32
|
+
})),
|
|
33
|
+
candidates: (input.candidates ?? []).map(normalizeCandidate),
|
|
34
|
+
outbox: Object.hasOwn(input, 'outbox') ? input.outbox : cloneSnapshot(outbox),
|
|
35
|
+
imports: Object.hasOwn(input, 'imports') ? input.imports : cloneSnapshot(imports)
|
|
36
|
+
};
|
|
37
|
+
return validateSnapshotRecords(normalized);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function cloneSnapshot(value) {
|
|
41
|
+
return JSON.parse(JSON.stringify(value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeCandidate(candidate) {
|
|
45
|
+
const status = candidate.status ?? CandidateStatus.PENDING;
|
|
46
|
+
const createdAt = candidate.createdAt ?? nowIso();
|
|
47
|
+
return {
|
|
48
|
+
...candidate,
|
|
49
|
+
targetSpaceId: candidate.targetSpaceId ?? null,
|
|
50
|
+
status,
|
|
51
|
+
createdAt,
|
|
52
|
+
decidedAt: candidate.decidedAt ?? (status === CandidateStatus.PENDING ? null : createdAt)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { CandidateStatus, nowIso } from '../../models.js';
|
|
4
|
+
import { validateCandidateRecord } from '../record-validation.js';
|
|
5
|
+
import { mapCandidate } from './mapper.js';
|
|
6
|
+
|
|
7
|
+
export class CandidatesRepository {
|
|
8
|
+
constructor(db) {
|
|
9
|
+
this.insertStatement = db.prepare(`
|
|
10
|
+
INSERT INTO candidates (
|
|
11
|
+
id, personal_space_id, target_space_id, episode_id, reason,
|
|
12
|
+
status, created_at, decided_at
|
|
13
|
+
) VALUES (
|
|
14
|
+
@id, @personalSpaceId, @targetSpaceId, @episodeId, @reason,
|
|
15
|
+
@status, @createdAt, @decidedAt
|
|
16
|
+
)
|
|
17
|
+
`);
|
|
18
|
+
this.getStatement = db.prepare('SELECT * FROM candidates WHERE id = ?');
|
|
19
|
+
this.listStatement = db.prepare('SELECT * FROM candidates ORDER BY rowid');
|
|
20
|
+
this.pendingStatement = db.prepare(`
|
|
21
|
+
SELECT * FROM candidates
|
|
22
|
+
WHERE personal_space_id = ? AND status = 'pending'
|
|
23
|
+
ORDER BY rowid
|
|
24
|
+
`);
|
|
25
|
+
this.updateStatusStatement = db.prepare(`
|
|
26
|
+
UPDATE candidates SET status = ?, decided_at = ? WHERE id = ?
|
|
27
|
+
`);
|
|
28
|
+
this.deleteStatement = db.prepare('DELETE FROM candidates');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
add(candidate) {
|
|
32
|
+
if (candidate.id && this.get(candidate.id)) {
|
|
33
|
+
throw new Error(`Duplicate candidate id: ${candidate.id}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const status = candidate.status ?? CandidateStatus.PENDING;
|
|
37
|
+
const createdAt = candidate.createdAt ?? nowIso();
|
|
38
|
+
const stored = {
|
|
39
|
+
id: candidate.id || randomUUID(),
|
|
40
|
+
personalSpaceId: candidate.personalSpaceId,
|
|
41
|
+
targetSpaceId: candidate.targetSpaceId ?? null,
|
|
42
|
+
episodeId: candidate.episodeId,
|
|
43
|
+
reason: candidate.reason,
|
|
44
|
+
status,
|
|
45
|
+
createdAt,
|
|
46
|
+
decidedAt: candidate.decidedAt ?? (
|
|
47
|
+
status === CandidateStatus.PENDING ? null : createdAt
|
|
48
|
+
)
|
|
49
|
+
};
|
|
50
|
+
validateCandidateRecord(stored);
|
|
51
|
+
this.insertStatement.run(stored);
|
|
52
|
+
return this.get(stored.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get(id) {
|
|
56
|
+
return mapCandidate(this.getStatement.get(id));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
list() {
|
|
60
|
+
return this.listStatement.all().map(mapCandidate);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pending(personalSpaceId) {
|
|
64
|
+
return this.pendingStatement.all(personalSpaceId).map(mapCandidate);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
updateStatus(id, status) {
|
|
68
|
+
const candidate = this.get(id);
|
|
69
|
+
if (!candidate) throw new Error(`Candidate not found: ${id}`);
|
|
70
|
+
const decidedAt = status === CandidateStatus.PENDING ? null : nowIso();
|
|
71
|
+
validateCandidateRecord({ ...candidate, status, decidedAt });
|
|
72
|
+
this.updateStatusStatement.run(status, decidedAt, id);
|
|
73
|
+
return this.get(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
insertSnapshot(candidate) {
|
|
77
|
+
validateCandidateRecord(candidate);
|
|
78
|
+
this.insertStatement.run(candidate);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
deleteAll() {
|
|
82
|
+
this.deleteStatement.run();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_EPISODE_METADATA_BYTES,
|
|
3
|
+
MAX_EPISODE_METADATA_FIELD_BYTES,
|
|
4
|
+
normalizeCorrectionEvidencePreviewOptions,
|
|
5
|
+
normalizeEvidencePreviewOptions,
|
|
6
|
+
previewEpisode
|
|
7
|
+
} from '../episode-evidence-preview.js';
|
|
8
|
+
import { sqliteEpisodeRestrictedSql } from './episode-privacy-sql.js';
|
|
9
|
+
|
|
10
|
+
export class EpisodeEvidenceRepository {
|
|
11
|
+
constructor(db) {
|
|
12
|
+
this.db = db;
|
|
13
|
+
this.correctionStatements = new Map();
|
|
14
|
+
this.sourceStatement = db.prepare(sourceQuery());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
source(spaceId, episodeId, options = {}) {
|
|
18
|
+
const normalized = normalizeEvidencePreviewOptions(options);
|
|
19
|
+
const row = this.sourceStatement.get({
|
|
20
|
+
spaceId, episodeId,
|
|
21
|
+
bodyChars: normalized.maxBodyBytes,
|
|
22
|
+
bodyBytes: normalized.maxBodyBytes,
|
|
23
|
+
metadataChars: MAX_EPISODE_METADATA_BYTES,
|
|
24
|
+
metadataBytes: MAX_EPISODE_METADATA_BYTES,
|
|
25
|
+
includeRestricted: Number(normalized.includeRestricted)
|
|
26
|
+
});
|
|
27
|
+
return previewEpisode(mapSourceRow(row), normalized);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
corrections(spaceId, factIds, options = {}) {
|
|
31
|
+
const normalized = normalizeCorrectionEvidencePreviewOptions(options);
|
|
32
|
+
if (factIds.length === 0) return [];
|
|
33
|
+
const statement = this.#correctionStatement(factIds.length);
|
|
34
|
+
const parameters = {
|
|
35
|
+
spaceId,
|
|
36
|
+
bodyChars: normalized.maxBodyBytes,
|
|
37
|
+
bodyBytes: normalized.maxBodyBytes,
|
|
38
|
+
fieldChars: MAX_EPISODE_METADATA_FIELD_BYTES,
|
|
39
|
+
fieldBytes: MAX_EPISODE_METADATA_FIELD_BYTES,
|
|
40
|
+
correctionLimit: normalized.maxCorrectionsPerFact,
|
|
41
|
+
includeRestricted: Number(normalized.includeRestricted)
|
|
42
|
+
};
|
|
43
|
+
factIds.forEach((factId, index) => { parameters[`factId${index}`] = factId; });
|
|
44
|
+
const groups = [...new Set(factIds)].map((factId) => ({
|
|
45
|
+
factId,
|
|
46
|
+
episodes: [],
|
|
47
|
+
truncated: false
|
|
48
|
+
}));
|
|
49
|
+
const byFactId = new Map(groups.map((group) => [group.factId, group]));
|
|
50
|
+
for (const row of statement.all(parameters)) {
|
|
51
|
+
const group = byFactId.get(row.fact_id);
|
|
52
|
+
group.episodes.push(previewEpisode(
|
|
53
|
+
mapCorrectionRow(row),
|
|
54
|
+
normalized,
|
|
55
|
+
{ correction: true }
|
|
56
|
+
));
|
|
57
|
+
group.truncated = row.correction_count > normalized.maxCorrectionsPerFact;
|
|
58
|
+
}
|
|
59
|
+
return groups;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#correctionStatement(factCount) {
|
|
63
|
+
if (!this.correctionStatements.has(factCount)) {
|
|
64
|
+
this.correctionStatements.set(factCount, this.db.prepare(correctionQuery(factCount)));
|
|
65
|
+
}
|
|
66
|
+
return this.correctionStatements.get(factCount);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sourceQuery() {
|
|
71
|
+
return `
|
|
72
|
+
SELECT id, space_id, source_kind,
|
|
73
|
+
substr(body, 1, @bodyChars) AS body_preview,
|
|
74
|
+
length(CAST(body AS BLOB)) > @bodyBytes AS body_truncated,
|
|
75
|
+
source_uri,
|
|
76
|
+
substr(metadata_json, 1, @metadataChars) AS metadata_preview,
|
|
77
|
+
length(CAST(metadata_json AS BLOB)) > @metadataBytes AS metadata_truncated,
|
|
78
|
+
created_at
|
|
79
|
+
FROM episodes source
|
|
80
|
+
WHERE id = @episodeId AND space_id = @spaceId
|
|
81
|
+
AND (@includeRestricted = 1 OR NOT ${sqliteEpisodeRestrictedSql('source')})
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function correctionQuery(factCount) {
|
|
86
|
+
const factIds = Array.from({ length: factCount }, (_, index) => `@factId${index}`).join(', ');
|
|
87
|
+
const fields = ['kind', 'factId', 'action'];
|
|
88
|
+
const selected = fields.map((field) => `
|
|
89
|
+
substr(CAST(json_extract(metadata_json, '$.${field}') AS TEXT), 1, @fieldChars)
|
|
90
|
+
AS metadata_${field.toLowerCase()}`
|
|
91
|
+
).join(',');
|
|
92
|
+
const clipped = fields.map((field) => `
|
|
93
|
+
length(CAST(json_extract(metadata_json, '$.${field}') AS BLOB)) > @fieldBytes`
|
|
94
|
+
).join(' OR ');
|
|
95
|
+
return `
|
|
96
|
+
WITH ranked AS (
|
|
97
|
+
SELECT correction.rowid AS correction_rowid,
|
|
98
|
+
json_extract(metadata_json, '$.factId') AS fact_id,
|
|
99
|
+
row_number() OVER (
|
|
100
|
+
PARTITION BY json_extract(metadata_json, '$.factId')
|
|
101
|
+
ORDER BY created_at DESC, correction.rowid DESC
|
|
102
|
+
) AS correction_rank,
|
|
103
|
+
count(*) OVER (
|
|
104
|
+
PARTITION BY json_extract(metadata_json, '$.factId')
|
|
105
|
+
) AS correction_count
|
|
106
|
+
FROM episodes correction
|
|
107
|
+
WHERE space_id = @spaceId
|
|
108
|
+
AND json_extract(metadata_json, '$.kind') = 'lens_correction'
|
|
109
|
+
AND json_extract(metadata_json, '$.factId') IN (${factIds})
|
|
110
|
+
AND (@includeRestricted = 1 OR NOT ${sqliteEpisodeRestrictedSql('correction')})
|
|
111
|
+
)
|
|
112
|
+
SELECT correction.id, correction.space_id, correction.source_kind,
|
|
113
|
+
substr(body, 1, @bodyChars) AS body_preview,
|
|
114
|
+
length(CAST(body AS BLOB)) > @bodyBytes AS body_truncated,
|
|
115
|
+
source_uri,${selected}, ranked.fact_id, ranked.correction_count,
|
|
116
|
+
((${clipped}) OR EXISTS (
|
|
117
|
+
SELECT 1 FROM json_each(metadata_json)
|
|
118
|
+
WHERE key NOT IN ('kind', 'factId', 'action')
|
|
119
|
+
)) AS metadata_truncated,
|
|
120
|
+
correction.created_at
|
|
121
|
+
FROM ranked
|
|
122
|
+
JOIN episodes correction ON correction.rowid = ranked.correction_rowid
|
|
123
|
+
WHERE ranked.correction_rank <= @correctionLimit
|
|
124
|
+
ORDER BY ranked.fact_id, ranked.correction_rank
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mapSourceRow(row) {
|
|
129
|
+
if (!row) return null;
|
|
130
|
+
return mapRow(row, row.metadata_truncated ? {} : JSON.parse(row.metadata_preview));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mapCorrectionRow(row) {
|
|
134
|
+
if (!row) return null;
|
|
135
|
+
const metadata = {};
|
|
136
|
+
for (const [key, column] of [
|
|
137
|
+
['kind', 'metadata_kind'], ['factId', 'metadata_factid'], ['action', 'metadata_action']
|
|
138
|
+
]) if (typeof row[column] === 'string') metadata[key] = row[column];
|
|
139
|
+
return mapRow(row, metadata);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function mapRow(row, metadata) {
|
|
143
|
+
return {
|
|
144
|
+
id: row.id,
|
|
145
|
+
spaceId: row.space_id,
|
|
146
|
+
sourceKind: row.source_kind,
|
|
147
|
+
body: row.body_preview,
|
|
148
|
+
bodyTruncated: row.body_truncated === 1,
|
|
149
|
+
sourceUri: row.source_uri,
|
|
150
|
+
metadata,
|
|
151
|
+
metadataTruncated: row.metadata_truncated === 1,
|
|
152
|
+
createdAt: row.created_at
|
|
153
|
+
};
|
|
154
|
+
}
|