agent-data-cli 0.1.0__py3-none-any.whl

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.
Files changed (97) hide show
  1. agent_data_cli/PYPI_README.md +72 -0
  2. agent_data_cli/__init__.py +2 -0
  3. agent_data_cli/__main__.py +7 -0
  4. agent_data_cli/cli/__init__.py +2 -0
  5. agent_data_cli/cli/__main__.py +7 -0
  6. agent_data_cli/cli/commands/__init__.py +85 -0
  7. agent_data_cli/cli/commands/channel.py +78 -0
  8. agent_data_cli/cli/commands/common.py +97 -0
  9. agent_data_cli/cli/commands/config.py +270 -0
  10. agent_data_cli/cli/commands/content/__init__.py +29 -0
  11. agent_data_cli/cli/commands/content/common.py +172 -0
  12. agent_data_cli/cli/commands/content/interact.py +74 -0
  13. agent_data_cli/cli/commands/content/query.py +111 -0
  14. agent_data_cli/cli/commands/content/search.py +75 -0
  15. agent_data_cli/cli/commands/content/update.py +198 -0
  16. agent_data_cli/cli/commands/dashboard.py +87 -0
  17. agent_data_cli/cli/commands/group.py +128 -0
  18. agent_data_cli/cli/commands/help.py +44 -0
  19. agent_data_cli/cli/commands/hub.py +107 -0
  20. agent_data_cli/cli/commands/init.py +29 -0
  21. agent_data_cli/cli/commands/source.py +41 -0
  22. agent_data_cli/cli/commands/specs.py +241 -0
  23. agent_data_cli/cli/commands/sub.py +60 -0
  24. agent_data_cli/cli/formatters.py +537 -0
  25. agent_data_cli/cli/help.py +149 -0
  26. agent_data_cli/cli/main.py +46 -0
  27. agent_data_cli/core/__init__.py +2 -0
  28. agent_data_cli/core/base.py +222 -0
  29. agent_data_cli/core/capabilities.py +105 -0
  30. agent_data_cli/core/config.py +236 -0
  31. agent_data_cli/core/discovery.py +158 -0
  32. agent_data_cli/core/help.py +16 -0
  33. agent_data_cli/core/manifest.py +329 -0
  34. agent_data_cli/core/models.py +296 -0
  35. agent_data_cli/core/protocol.py +135 -0
  36. agent_data_cli/core/registry.py +353 -0
  37. agent_data_cli/core/source_defaults.py +24 -0
  38. agent_data_cli/dashboard/__init__.py +2 -0
  39. agent_data_cli/dashboard/adapters/__init__.py +2 -0
  40. agent_data_cli/dashboard/adapters/channel.py +73 -0
  41. agent_data_cli/dashboard/adapters/config.py +153 -0
  42. agent_data_cli/dashboard/adapters/content.py +350 -0
  43. agent_data_cli/dashboard/adapters/group.py +47 -0
  44. agent_data_cli/dashboard/adapters/help.py +32 -0
  45. agent_data_cli/dashboard/adapters/source.py +61 -0
  46. agent_data_cli/dashboard/adapters/sub.py +28 -0
  47. agent_data_cli/dashboard/context.py +29 -0
  48. agent_data_cli/dashboard/index.py +30 -0
  49. agent_data_cli/dashboard/pages/01_Source.py +57 -0
  50. agent_data_cli/dashboard/pages/02_Channel.py +99 -0
  51. agent_data_cli/dashboard/pages/03_Content_Search.py +64 -0
  52. agent_data_cli/dashboard/pages/04_Content_Query.py +79 -0
  53. agent_data_cli/dashboard/pages/05_Content_Update.py +103 -0
  54. agent_data_cli/dashboard/pages/06_Sub.py +51 -0
  55. agent_data_cli/dashboard/pages/07_Group.py +116 -0
  56. agent_data_cli/dashboard/pages/08_Config.py +114 -0
  57. agent_data_cli/dashboard/pages/09_Help.py +48 -0
  58. agent_data_cli/dashboard/pages/__init__.py +2 -0
  59. agent_data_cli/dashboard/runtime.py +208 -0
  60. agent_data_cli/dashboard/state.py +60 -0
  61. agent_data_cli/dashboard/widgets/__init__.py +2 -0
  62. agent_data_cli/dashboard/widgets/common.py +90 -0
  63. agent_data_cli/dashboard/widgets/forms.py +29 -0
  64. agent_data_cli/dashboard/widgets/tables.py +10 -0
  65. agent_data_cli/fetchers/__init__.py +2 -0
  66. agent_data_cli/fetchers/base.py +61 -0
  67. agent_data_cli/fetchers/browser.py +44 -0
  68. agent_data_cli/fetchers/http.py +313 -0
  69. agent_data_cli/fetchers/jina.py +44 -0
  70. agent_data_cli/hub/__init__.py +6 -0
  71. agent_data_cli/hub/models.py +20 -0
  72. agent_data_cli/hub/service.py +210 -0
  73. agent_data_cli/init_service.py +29 -0
  74. agent_data_cli/main.py +72 -0
  75. agent_data_cli/migration.py +53 -0
  76. agent_data_cli/runtime_paths.py +90 -0
  77. agent_data_cli/store/__init__.py +2 -0
  78. agent_data_cli/store/audit.py +42 -0
  79. agent_data_cli/store/channels.py +80 -0
  80. agent_data_cli/store/configs.py +134 -0
  81. agent_data_cli/store/content.py +770 -0
  82. agent_data_cli/store/db.py +298 -0
  83. agent_data_cli/store/groups.py +120 -0
  84. agent_data_cli/store/health.py +53 -0
  85. agent_data_cli/store/migrations.py +176 -0
  86. agent_data_cli/store/repositories.py +136 -0
  87. agent_data_cli/store/subscriptions.py +119 -0
  88. agent_data_cli/utils/__init__.py +2 -0
  89. agent_data_cli/utils/text.py +21 -0
  90. agent_data_cli/utils/time.py +63 -0
  91. agent_data_cli/utils/urls.py +8 -0
  92. agent_data_cli-0.1.0.dist-info/METADATA +104 -0
  93. agent_data_cli-0.1.0.dist-info/RECORD +97 -0
  94. agent_data_cli-0.1.0.dist-info/WHEEL +5 -0
  95. agent_data_cli-0.1.0.dist-info/entry_points.txt +2 -0
  96. agent_data_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  97. agent_data_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sqlite3
5
+
6
+ from agent_data_cli.core.models import (
7
+ ActionAuditRecord,
8
+ ChannelRecord,
9
+ ContentBatchWriteResult,
10
+ ContentQueryRow,
11
+ ContentSyncBatch,
12
+ ContentNode,
13
+ ContentRecord,
14
+ HealthRecord,
15
+ SourceDescriptor,
16
+ SourceStorageSpec,
17
+ SubscriptionRecord,
18
+ )
19
+
20
+ from . import audit as audit_store
21
+ from . import channels as channel_store
22
+ from . import configs as config_store
23
+ from . import content as content_store
24
+ from . import groups as group_store
25
+ from . import health as health_store
26
+ from .migrations import SCHEMA
27
+ from . import subscriptions as subscription_store
28
+
29
+
30
+ class Store:
31
+ def __init__(self, path: str) -> None:
32
+ self.path = path
33
+ self._connection = sqlite3.connect(self.path)
34
+ self._connection.row_factory = sqlite3.Row
35
+ self._storage_specs: dict[str, SourceStorageSpec] = {}
36
+
37
+ def init_schema(self, storage_specs: list[SourceStorageSpec] | None = None) -> None:
38
+ if storage_specs is not None:
39
+ self.set_storage_specs(storage_specs)
40
+ with self._connect() as connection:
41
+ connection.executescript(SCHEMA)
42
+
43
+ def set_storage_specs(self, storage_specs: list[SourceStorageSpec]) -> None:
44
+ specs_by_source: dict[str, SourceStorageSpec] = {}
45
+ for spec in storage_specs:
46
+ if spec.source in specs_by_source:
47
+ raise RuntimeError(f"duplicate storage spec for source: {spec.source}")
48
+ specs_by_source[spec.source] = spec
49
+ self._storage_specs = specs_by_source
50
+
51
+ def upsert_source(self, source: SourceDescriptor) -> None:
52
+ with self._connect() as connection:
53
+ channel_store.upsert_source(connection, source)
54
+
55
+ def upsert_channel(self, channel: ChannelRecord) -> None:
56
+ with self._connect() as connection:
57
+ channel_store.upsert_channel(connection, channel)
58
+
59
+ def get_channel(self, source: str, channel_key: str) -> ChannelRecord | None:
60
+ with self._connect() as connection:
61
+ return channel_store.get_channel(connection, source, channel_key)
62
+
63
+ def add_subscription(
64
+ self,
65
+ source: str,
66
+ channel_key: str,
67
+ display_name: str,
68
+ metadata: dict[str, str],
69
+ ) -> SubscriptionRecord:
70
+ with self._connect() as connection:
71
+ return subscription_store.add_subscription(
72
+ connection,
73
+ source=source,
74
+ channel_key=channel_key,
75
+ display_name=display_name,
76
+ metadata=metadata,
77
+ )
78
+
79
+ def set_source_config(
80
+ self,
81
+ source: str,
82
+ key: str,
83
+ value: str,
84
+ value_type: str,
85
+ is_secret: bool,
86
+ ) -> None:
87
+ with self._connect() as connection:
88
+ config_store.set_source_config(connection, source, key, value, value_type, is_secret)
89
+
90
+ def unset_source_config(self, source: str, key: str) -> None:
91
+ with self._connect() as connection:
92
+ config_store.unset_source_config(connection, source, key)
93
+
94
+ def set_cli_config(
95
+ self,
96
+ key: str,
97
+ value: str,
98
+ value_type: str,
99
+ is_secret: bool,
100
+ ) -> None:
101
+ with self._connect() as connection:
102
+ config_store.set_cli_config(connection, key, value, value_type, is_secret)
103
+
104
+ def unset_cli_config(self, key: str) -> None:
105
+ with self._connect() as connection:
106
+ config_store.unset_cli_config(connection, key)
107
+
108
+ def list_cli_configs(self):
109
+ with self._connect() as connection:
110
+ return config_store.list_cli_configs(connection)
111
+
112
+ def get_cli_config_map(self):
113
+ return {entry.key: entry for entry in self.list_cli_configs()}
114
+
115
+ def list_source_configs(self, source: str | None = None):
116
+ with self._connect() as connection:
117
+ return config_store.list_source_configs(connection, source)
118
+
119
+ def get_source_config_map(self, source: str):
120
+ return {
121
+ entry.key: entry
122
+ for entry in self.list_source_configs(source)
123
+ }
124
+
125
+ def prune_source_configs(self, allowed_keys_by_source: dict[str, set[str]]) -> None:
126
+ with self._connect() as connection:
127
+ config_store.prune_source_configs(connection, allowed_keys_by_source)
128
+
129
+ def prune_cli_configs(self, allowed_keys: set[str]) -> None:
130
+ with self._connect() as connection:
131
+ config_store.prune_cli_configs(connection, allowed_keys)
132
+
133
+ def purge_source_state(self, source: str) -> None:
134
+ with self._connect() as connection:
135
+ group_store.delete_source_group_members(connection, source)
136
+ subscription_store.delete_source_subscriptions(connection, source)
137
+ config_store.delete_source_configs(connection, source)
138
+ content_store.delete_source_sync_state(connection, source)
139
+ health_store.delete_health(connection, source)
140
+ audit_store.delete_action_audits(connection, source)
141
+ content_store.delete_source_content(connection, source)
142
+ channel_store.delete_channels(connection, source)
143
+ channel_store.delete_source(connection, source)
144
+
145
+ def create_group(self, group_name: str) -> None:
146
+ with self._connect() as connection:
147
+ group_store.create_group(connection, group_name)
148
+
149
+ def delete_group(self, group_name: str) -> None:
150
+ with self._connect() as connection:
151
+ group_store.delete_group(connection, group_name)
152
+
153
+ def list_groups(self):
154
+ with self._connect() as connection:
155
+ return group_store.list_groups(connection)
156
+
157
+ def add_group_source(self, group_name: str, source: str) -> None:
158
+ with self._connect() as connection:
159
+ group_store.add_group_source(connection, group_name, source)
160
+
161
+ def add_group_channel(self, group_name: str, source: str, channel_key: str) -> None:
162
+ with self._connect() as connection:
163
+ group_store.add_group_channel(connection, group_name, source, channel_key)
164
+
165
+ def remove_group_source(self, group_name: str, source: str) -> None:
166
+ with self._connect() as connection:
167
+ group_store.remove_group_source(connection, group_name, source)
168
+
169
+ def remove_group_channel(self, group_name: str, source: str, channel_key: str) -> None:
170
+ with self._connect() as connection:
171
+ group_store.remove_group_channel(connection, group_name, source, channel_key)
172
+
173
+ def list_group_members(self, group_name: str):
174
+ with self._connect() as connection:
175
+ return group_store.list_group_members(connection, group_name)
176
+
177
+ def expand_group_update_targets(self, group_name: str) -> list[tuple[str, str]]:
178
+ with self._connect() as connection:
179
+ return group_store.expand_group_update_targets(connection, group_name)
180
+
181
+ def remove_subscription(self, source: str, channel_key: str) -> None:
182
+ with self._connect() as connection:
183
+ subscription_store.remove_subscription(connection, source, channel_key)
184
+
185
+ def list_subscriptions(self, source: str | None = None) -> list[SubscriptionRecord]:
186
+ with self._connect() as connection:
187
+ return subscription_store.list_subscriptions(connection, source)
188
+
189
+ def is_subscribed(self, source: str, channel_key: str) -> bool:
190
+ with self._connect() as connection:
191
+ return subscription_store.is_subscribed(connection, source, channel_key)
192
+
193
+ def upsert_content(self, record: ContentRecord) -> bool:
194
+ with self._connect() as connection:
195
+ return content_store.upsert_content(connection, "", record)
196
+
197
+ def write_content_batch(
198
+ self,
199
+ source: str,
200
+ channel_key: str,
201
+ batch: ContentSyncBatch,
202
+ ) -> ContentBatchWriteResult:
203
+ with self._connect() as connection:
204
+ return content_store.write_content_batch(
205
+ connection,
206
+ source=source,
207
+ channel_key=channel_key,
208
+ batch=batch,
209
+ )
210
+
211
+ def insert_action_audit(self, record: ActionAuditRecord) -> None:
212
+ with self._connect() as connection:
213
+ audit_store.insert_action_audit(connection, record)
214
+
215
+ def set_sync_state(self, source: str, channel_key: str, cursor: str) -> None:
216
+ with self._connect() as connection:
217
+ content_store.set_sync_state(connection, source, channel_key, cursor)
218
+
219
+ def save_health(self, record: HealthRecord) -> None:
220
+ with self._connect() as connection:
221
+ health_store.save_health(connection, record)
222
+
223
+ def get_latest_health(self, source: str) -> HealthRecord | None:
224
+ with self._connect() as connection:
225
+ return health_store.get_latest_health(connection, source)
226
+
227
+ def list_content(
228
+ self,
229
+ source: str,
230
+ channel_key: str,
231
+ *,
232
+ record_type: str | None = None,
233
+ limit: int = 10,
234
+ since: str | None = None,
235
+ fetch_all: bool = False,
236
+ ) -> list[ContentRecord]:
237
+ with self._connect() as connection:
238
+ return content_store.list_content(
239
+ connection,
240
+ table_name="",
241
+ source=source,
242
+ channel_key=channel_key,
243
+ record_type=record_type,
244
+ limit=limit,
245
+ since=since,
246
+ fetch_all=fetch_all,
247
+ )
248
+
249
+ def query_content(
250
+ self,
251
+ *,
252
+ source: str | None = None,
253
+ channel_key: str | None = None,
254
+ group_name: str | None = None,
255
+ record_type: str | None = None,
256
+ parent_ref: str | None = None,
257
+ children_ref: str | None = None,
258
+ depth: int = 1,
259
+ since: str | None = None,
260
+ keywords: str | None = None,
261
+ limit: int = 10,
262
+ fetch_all: bool = False,
263
+ ) -> list[ContentQueryRow]:
264
+ with self._connect() as connection:
265
+ return content_store.query_content(
266
+ connection,
267
+ storage_specs=self._storage_specs,
268
+ source=source,
269
+ channel_key=channel_key,
270
+ group_name=group_name,
271
+ record_type=record_type,
272
+ parent_ref=parent_ref,
273
+ children_ref=children_ref,
274
+ depth=depth,
275
+ since=since,
276
+ keywords=keywords,
277
+ limit=limit,
278
+ fetch_all=fetch_all,
279
+ )
280
+
281
+ def list_content_channels(self, source: str, content_key: str) -> tuple[str, ...]:
282
+ with self._connect() as connection:
283
+ return content_store.list_content_channels(connection, source, content_key)
284
+
285
+ def _connect(self) -> sqlite3.Connection:
286
+ return self._connection
287
+
288
+ def _table_for_source(self, source: str) -> str:
289
+ return self._require_storage_spec(source).table_name
290
+
291
+ def _require_storage_spec(self, source: str) -> SourceStorageSpec:
292
+ try:
293
+ return self._storage_specs[source]
294
+ except KeyError as exc:
295
+ raise RuntimeError(f"no storage spec registered for source: {source}") from exc
296
+
297
+ def _is_valid_identifier(self, value: str) -> bool:
298
+ return re.fullmatch(r"[a-z][a-z0-9_]*", value) is not None
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+ from agent_data_cli.utils.time import utc_now_iso
6
+
7
+ from .repositories import row_to_group, row_to_group_member, row_to_subscription
8
+
9
+
10
+ def create_group(connection: sqlite3.Connection, group_name: str) -> None:
11
+ connection.execute(
12
+ """
13
+ INSERT INTO groups (group_name, created_at)
14
+ VALUES (?, ?)
15
+ ON CONFLICT(group_name) DO NOTHING
16
+ """,
17
+ (group_name, utc_now_iso()),
18
+ )
19
+
20
+
21
+ def delete_group(connection: sqlite3.Connection, group_name: str) -> None:
22
+ connection.execute("DELETE FROM group_members WHERE group_name = ?", (group_name,))
23
+ connection.execute("DELETE FROM groups WHERE group_name = ?", (group_name,))
24
+
25
+
26
+ def list_groups(connection: sqlite3.Connection):
27
+ rows = connection.execute(
28
+ "SELECT group_name, created_at FROM groups ORDER BY group_name"
29
+ ).fetchall()
30
+ return [row_to_group(row) for row in rows]
31
+
32
+
33
+ def add_group_source(connection: sqlite3.Connection, group_name: str, source: str) -> None:
34
+ create_group(connection, group_name)
35
+ connection.execute(
36
+ """
37
+ INSERT INTO group_members (group_name, member_type, source, channel_key)
38
+ VALUES (?, 'source', ?, '')
39
+ ON CONFLICT(group_name, member_type, source, channel_key) DO NOTHING
40
+ """,
41
+ (group_name, source),
42
+ )
43
+
44
+
45
+ def add_group_channel(connection: sqlite3.Connection, group_name: str, source: str, channel_key: str) -> None:
46
+ create_group(connection, group_name)
47
+ connection.execute(
48
+ """
49
+ INSERT INTO group_members (group_name, member_type, source, channel_key)
50
+ VALUES (?, 'channel', ?, ?)
51
+ ON CONFLICT(group_name, member_type, source, channel_key) DO NOTHING
52
+ """,
53
+ (group_name, source, channel_key),
54
+ )
55
+
56
+
57
+ def remove_group_source(connection: sqlite3.Connection, group_name: str, source: str) -> None:
58
+ connection.execute(
59
+ """
60
+ DELETE FROM group_members
61
+ WHERE group_name = ? AND member_type = 'source' AND source = ? AND channel_key = ''
62
+ """,
63
+ (group_name, source),
64
+ )
65
+
66
+
67
+ def remove_group_channel(connection: sqlite3.Connection, group_name: str, source: str, channel_key: str) -> None:
68
+ connection.execute(
69
+ """
70
+ DELETE FROM group_members
71
+ WHERE group_name = ? AND member_type = 'channel' AND source = ? AND channel_key = ?
72
+ """,
73
+ (group_name, source, channel_key),
74
+ )
75
+
76
+
77
+ def delete_source_group_members(connection: sqlite3.Connection, source: str) -> None:
78
+ connection.execute("DELETE FROM group_members WHERE source = ?", (source,))
79
+
80
+
81
+ def list_group_members(connection: sqlite3.Connection, group_name: str):
82
+ rows = connection.execute(
83
+ """
84
+ SELECT group_name, member_type, source, channel_key
85
+ FROM group_members
86
+ WHERE group_name = ?
87
+ ORDER BY member_type, source, channel_key
88
+ """,
89
+ (group_name,),
90
+ ).fetchall()
91
+ return [row_to_group_member(row) for row in rows]
92
+
93
+
94
+ def expand_group_update_targets(connection: sqlite3.Connection, group_name: str) -> list[tuple[str, str]]:
95
+ targets: set[tuple[str, str]] = set()
96
+ for member in list_group_members(connection, group_name):
97
+ if member.member_type == "channel" and member.channel_key:
98
+ targets.add((member.source, member.channel_key))
99
+ continue
100
+ if member.member_type == "source":
101
+ rows = connection.execute(
102
+ """
103
+ SELECT
104
+ subscription_id,
105
+ source,
106
+ channel_key,
107
+ display_name,
108
+ created_at,
109
+ last_updated_at,
110
+ enabled,
111
+ metadata_json
112
+ FROM subscriptions
113
+ WHERE source = ?
114
+ ORDER BY channel_key
115
+ """,
116
+ (member.source,),
117
+ ).fetchall()
118
+ for subscription in (row_to_subscription(row) for row in rows):
119
+ targets.add((subscription.source, subscription.channel_key))
120
+ return sorted(targets)
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+ from agent_data_cli.core.models import HealthRecord
6
+
7
+ from .repositories import row_to_health
8
+
9
+
10
+ def save_health(connection: sqlite3.Connection, record: HealthRecord) -> None:
11
+ connection.execute(
12
+ """
13
+ INSERT INTO health_checks (
14
+ source,
15
+ status,
16
+ checked_at,
17
+ latency_ms,
18
+ error,
19
+ details
20
+ )
21
+ VALUES (?, ?, ?, ?, ?, ?)
22
+ ON CONFLICT(source) DO UPDATE SET
23
+ status = excluded.status,
24
+ checked_at = excluded.checked_at,
25
+ latency_ms = excluded.latency_ms,
26
+ error = excluded.error,
27
+ details = excluded.details
28
+ """,
29
+ (
30
+ record.source,
31
+ record.status,
32
+ record.checked_at,
33
+ record.latency_ms,
34
+ record.error,
35
+ record.details,
36
+ ),
37
+ )
38
+
39
+
40
+ def get_latest_health(connection: sqlite3.Connection, source: str) -> HealthRecord | None:
41
+ row = connection.execute(
42
+ """
43
+ SELECT source, status, checked_at, latency_ms, error, details
44
+ FROM health_checks
45
+ WHERE source = ?
46
+ """,
47
+ (source,),
48
+ ).fetchone()
49
+ return row_to_health(row)
50
+
51
+
52
+ def delete_health(connection: sqlite3.Connection, source: str) -> None:
53
+ connection.execute("DELETE FROM health_checks WHERE source = ?", (source,))
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ SCHEMA = """
5
+ CREATE TABLE IF NOT EXISTS sources (
6
+ name TEXT PRIMARY KEY,
7
+ display_name TEXT NOT NULL,
8
+ summary TEXT NOT NULL
9
+ );
10
+
11
+ CREATE TABLE IF NOT EXISTS channels (
12
+ source TEXT NOT NULL,
13
+ channel_id TEXT NOT NULL,
14
+ channel_key TEXT NOT NULL,
15
+ display_name TEXT NOT NULL,
16
+ url TEXT NOT NULL,
17
+ metadata_json TEXT NOT NULL,
18
+ PRIMARY KEY (source, channel_key)
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS subscriptions (
22
+ subscription_id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ source TEXT NOT NULL,
24
+ channel_key TEXT NOT NULL,
25
+ display_name TEXT NOT NULL,
26
+ created_at TEXT NOT NULL,
27
+ last_updated_at TEXT,
28
+ enabled INTEGER NOT NULL,
29
+ metadata_json TEXT NOT NULL,
30
+ UNIQUE (source, channel_key)
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS source_configs (
34
+ source TEXT NOT NULL,
35
+ key TEXT NOT NULL,
36
+ value TEXT NOT NULL,
37
+ value_type TEXT NOT NULL,
38
+ is_secret INTEGER NOT NULL,
39
+ updated_at TEXT NOT NULL,
40
+ PRIMARY KEY (source, key)
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS cli_configs (
44
+ key TEXT PRIMARY KEY,
45
+ value TEXT NOT NULL,
46
+ value_type TEXT NOT NULL,
47
+ is_secret INTEGER NOT NULL,
48
+ updated_at TEXT NOT NULL
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS groups (
52
+ group_name TEXT PRIMARY KEY,
53
+ created_at TEXT NOT NULL
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS group_members (
57
+ group_name TEXT NOT NULL,
58
+ member_type TEXT NOT NULL,
59
+ source TEXT NOT NULL,
60
+ channel_key TEXT NOT NULL,
61
+ PRIMARY KEY (group_name, member_type, source, channel_key)
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS sync_state (
65
+ source TEXT NOT NULL,
66
+ channel_key TEXT NOT NULL,
67
+ cursor TEXT NOT NULL,
68
+ updated_at TEXT NOT NULL,
69
+ PRIMARY KEY (source, channel_key)
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS health_checks (
73
+ source TEXT PRIMARY KEY,
74
+ status TEXT NOT NULL,
75
+ checked_at TEXT NOT NULL,
76
+ latency_ms INTEGER NOT NULL,
77
+ error TEXT,
78
+ details TEXT NOT NULL
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS action_audits (
82
+ audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ executed_at TEXT NOT NULL,
84
+ action TEXT NOT NULL,
85
+ source TEXT NOT NULL,
86
+ mode TEXT,
87
+ target_kind TEXT NOT NULL,
88
+ targets_json TEXT NOT NULL,
89
+ params_summary TEXT NOT NULL,
90
+ status TEXT NOT NULL,
91
+ error TEXT,
92
+ dry_run INTEGER NOT NULL
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS content_nodes (
96
+ node_id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ source TEXT NOT NULL,
98
+ content_key TEXT NOT NULL,
99
+ content_type TEXT NOT NULL,
100
+ external_id TEXT NOT NULL,
101
+ title TEXT NOT NULL,
102
+ url TEXT NOT NULL,
103
+ snippet TEXT NOT NULL,
104
+ author TEXT,
105
+ published_at TEXT,
106
+ fetched_at TEXT NOT NULL,
107
+ raw_payload TEXT NOT NULL,
108
+ content_ref TEXT,
109
+ UNIQUE (source, content_key)
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_content_nodes_source_time
113
+ ON content_nodes(source, published_at DESC, node_id DESC);
114
+
115
+ CREATE INDEX IF NOT EXISTS idx_content_nodes_source_type_time
116
+ ON content_nodes(source, content_type, published_at DESC, node_id DESC);
117
+
118
+ CREATE TABLE IF NOT EXISTS content_channel_links (
119
+ source TEXT NOT NULL,
120
+ channel_key TEXT NOT NULL,
121
+ content_key TEXT NOT NULL,
122
+ membership_kind TEXT NOT NULL,
123
+ linked_at TEXT NOT NULL,
124
+ PRIMARY KEY (source, channel_key, content_key)
125
+ );
126
+
127
+ CREATE INDEX IF NOT EXISTS idx_content_channel_links_source_channel
128
+ ON content_channel_links(source, channel_key, membership_kind);
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_content_channel_links_source_content
131
+ ON content_channel_links(source, content_key);
132
+
133
+ CREATE TABLE IF NOT EXISTS content_relations (
134
+ source TEXT NOT NULL,
135
+ from_content_key TEXT NOT NULL,
136
+ relation_type TEXT NOT NULL,
137
+ to_content_key TEXT NOT NULL,
138
+ relation_semantic TEXT,
139
+ position INTEGER,
140
+ metadata_json TEXT NOT NULL,
141
+ PRIMARY KEY (source, from_content_key, relation_type, to_content_key)
142
+ );
143
+
144
+ CREATE INDEX IF NOT EXISTS idx_content_relations_source_parent
145
+ ON content_relations(source, to_content_key, relation_type, position);
146
+
147
+ CREATE INDEX IF NOT EXISTS idx_content_relations_source_child
148
+ ON content_relations(source, from_content_key, relation_type, position);
149
+ """
150
+
151
+
152
+ def build_content_table_schema(table_name: str) -> str:
153
+ return f"""
154
+ CREATE TABLE IF NOT EXISTS {table_name} (
155
+ record_id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ source TEXT NOT NULL,
157
+ channel_key TEXT NOT NULL,
158
+ record_type TEXT NOT NULL,
159
+ external_id TEXT NOT NULL,
160
+ title TEXT NOT NULL,
161
+ url TEXT NOT NULL,
162
+ snippet TEXT NOT NULL,
163
+ author TEXT,
164
+ published_at TEXT,
165
+ fetched_at TEXT NOT NULL,
166
+ raw_payload TEXT NOT NULL,
167
+ dedup_key TEXT NOT NULL UNIQUE,
168
+ content_ref TEXT
169
+ );
170
+
171
+ CREATE INDEX IF NOT EXISTS idx_{table_name}_channel_type_time
172
+ ON {table_name}(channel_key, record_type, published_at DESC, record_id DESC);
173
+
174
+ CREATE INDEX IF NOT EXISTS idx_{table_name}_time
175
+ ON {table_name}(published_at DESC, record_id DESC);
176
+ """