golded-ftn-sqlite 1.0.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.
@@ -0,0 +1,15 @@
1
+ """SQLite message bases for FTN netmail and echomail."""
2
+
3
+ from .database import SqliteDatabase
4
+ from .models import AreaDefinition, AreaKind
5
+ from .reader import SqliteReader
6
+ from .writer import SqliteSession, SqliteWriter
7
+
8
+ __all__ = [
9
+ "AreaDefinition",
10
+ "AreaKind",
11
+ "SqliteDatabase",
12
+ "SqliteReader",
13
+ "SqliteSession",
14
+ "SqliteWriter",
15
+ ]
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sqlite3
5
+ from os import PathLike
6
+ from pathlib import Path
7
+ from typing import Self, cast
8
+
9
+ from golded_ftn import FtnAddress
10
+
11
+ from .models import AreaDefinition, AreaKind
12
+
13
+ APPLICATION_ID = 0x47465453
14
+ SCHEMA_VERSION = 1
15
+
16
+ _SCHEMA = f"""
17
+ PRAGMA application_id = {APPLICATION_ID};
18
+ PRAGMA user_version = {SCHEMA_VERSION};
19
+
20
+ CREATE TABLE areas (
21
+ id INTEGER PRIMARY KEY,
22
+ code TEXT NOT NULL COLLATE NOCASE UNIQUE,
23
+ name TEXT NOT NULL,
24
+ kind TEXT NOT NULL CHECK (kind IN ('netmail', 'echomail')),
25
+ sort_order INTEGER NOT NULL DEFAULT 0,
26
+ meta_key TEXT,
27
+ origin_address TEXT
28
+ CHECK (
29
+ origin_address IS NULL
30
+ OR (origin_address != '' AND origin_address = trim(origin_address))
31
+ ),
32
+ next_message_number INTEGER NOT NULL DEFAULT 1
33
+ CHECK (next_message_number > 0)
34
+ ) STRICT;
35
+
36
+ CREATE TABLE messages (
37
+ id INTEGER PRIMARY KEY,
38
+ area_id INTEGER NOT NULL REFERENCES areas(id),
39
+ msgno INTEGER NOT NULL CHECK (msgno > 0),
40
+ from_name TEXT NOT NULL,
41
+ to_name TEXT NOT NULL,
42
+ subject TEXT NOT NULL,
43
+ body_text TEXT NOT NULL,
44
+ posted_at TEXT,
45
+ external_id TEXT,
46
+ attributes_raw INTEGER NOT NULL,
47
+ from_address TEXT
48
+ CHECK (
49
+ from_address IS NULL
50
+ OR (from_address != '' AND from_address = trim(from_address))
51
+ ),
52
+ to_address TEXT
53
+ CHECK (
54
+ to_address IS NULL
55
+ OR (to_address != '' AND to_address = trim(to_address))
56
+ ),
57
+ reply_to_msgno INTEGER,
58
+ reply1st_msgno INTEGER,
59
+ reply_next_msgno INTEGER,
60
+ source_type TEXT,
61
+ source_path TEXT,
62
+ source_id TEXT,
63
+ source_offset INTEGER,
64
+ revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
65
+ deleted_at TEXT,
66
+ UNIQUE (area_id, msgno),
67
+ CHECK (
68
+ source_type IS NOT NULL
69
+ OR (source_path IS NULL AND source_id IS NULL AND source_offset IS NULL)
70
+ )
71
+ ) STRICT;
72
+
73
+ CREATE INDEX messages_area_posted
74
+ ON messages (area_id, posted_at, msgno)
75
+ WHERE deleted_at IS NULL;
76
+
77
+ CREATE INDEX messages_external_id
78
+ ON messages (external_id)
79
+ WHERE external_id IS NOT NULL AND deleted_at IS NULL;
80
+
81
+ CREATE TRIGGER messages_netmail_addresses_insert
82
+ BEFORE INSERT ON messages
83
+ WHEN (SELECT kind FROM areas WHERE id = NEW.area_id) = 'netmail'
84
+ AND (NEW.from_address IS NULL OR NEW.to_address IS NULL)
85
+ BEGIN
86
+ SELECT RAISE(ABORT, 'netmail requires origin and destination addresses');
87
+ END;
88
+
89
+ CREATE TRIGGER messages_netmail_addresses_update
90
+ BEFORE UPDATE OF area_id, from_address, to_address ON messages
91
+ WHEN (SELECT kind FROM areas WHERE id = NEW.area_id) = 'netmail'
92
+ AND (NEW.from_address IS NULL OR NEW.to_address IS NULL)
93
+ BEGIN
94
+ SELECT RAISE(ABORT, 'netmail requires origin and destination addresses');
95
+ END;
96
+
97
+ CREATE TABLE control_lines (
98
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
99
+ position INTEGER NOT NULL CHECK (position >= 0),
100
+ name TEXT NOT NULL COLLATE NOCASE,
101
+ value TEXT NOT NULL,
102
+ raw TEXT NOT NULL,
103
+ PRIMARY KEY (message_id, position)
104
+ ) WITHOUT ROWID, STRICT;
105
+
106
+ CREATE INDEX control_lines_name
107
+ ON control_lines (name, message_id);
108
+
109
+ CREATE TABLE routing_entries (
110
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
111
+ kind TEXT NOT NULL CHECK (kind IN ('seen_by', 'path')),
112
+ position INTEGER NOT NULL CHECK (position >= 0),
113
+ value TEXT NOT NULL,
114
+ PRIMARY KEY (message_id, kind, position)
115
+ ) WITHOUT ROWID, STRICT;
116
+
117
+ CREATE TABLE reply_list_entries (
118
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
119
+ position INTEGER NOT NULL CHECK (position >= 0),
120
+ msgno INTEGER NOT NULL CHECK (msgno > 0),
121
+ PRIMARY KEY (message_id, position)
122
+ ) WITHOUT ROWID, STRICT;
123
+ """
124
+
125
+
126
+ def format_address(address: FtnAddress | None) -> str | None:
127
+ return None if address is None else str(address)
128
+
129
+
130
+ def parse_address(value: str | None) -> FtnAddress | None:
131
+ return None if value is None else FtnAddress.from_string(value)
132
+
133
+
134
+ class SqliteDatabase:
135
+ def __init__(self, path: Path) -> None:
136
+ self.path = path
137
+
138
+ @classmethod
139
+ def open(cls, path: str | PathLike[str]) -> Self:
140
+ database_path = Path(path).resolve()
141
+ connection = connect_database(database_path, read_only=True)
142
+ connection.close()
143
+ return cls(database_path)
144
+
145
+ @classmethod
146
+ def create(
147
+ cls,
148
+ path: str | PathLike[str],
149
+ *,
150
+ area: AreaDefinition,
151
+ ) -> Self:
152
+ database_path = Path(path).resolve()
153
+ database_path.parent.mkdir(parents=True, exist_ok=True)
154
+ descriptor = os.open(database_path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600)
155
+ os.close(descriptor)
156
+ try:
157
+ with sqlite3.connect(database_path) as connection:
158
+ connection.execute("PRAGMA journal_mode = WAL")
159
+ connection.execute("PRAGMA synchronous = NORMAL")
160
+ connection.executescript(_SCHEMA)
161
+ connection.execute(
162
+ """
163
+ INSERT INTO areas (
164
+ code, name, kind, sort_order, meta_key, origin_address
165
+ ) VALUES (?, ?, ?, ?, ?, ?)
166
+ """,
167
+ (
168
+ area.code,
169
+ area.name,
170
+ area.kind.value,
171
+ area.sort_order,
172
+ area.meta_key,
173
+ format_address(area.default_origin),
174
+ ),
175
+ )
176
+ except BaseException:
177
+ database_path.unlink(missing_ok=True)
178
+ raise
179
+ return cls(database_path)
180
+
181
+ def add_area(self, area: AreaDefinition) -> None:
182
+ with sqlite3.connect(self.path) as connection:
183
+ connection.execute(
184
+ """
185
+ INSERT INTO areas (
186
+ code, name, kind, sort_order, meta_key, origin_address
187
+ ) VALUES (?, ?, ?, ?, ?, ?)
188
+ """,
189
+ (
190
+ area.code,
191
+ area.name,
192
+ area.kind.value,
193
+ area.sort_order,
194
+ area.meta_key,
195
+ format_address(area.default_origin),
196
+ ),
197
+ )
198
+
199
+ def areas(self) -> tuple[AreaDefinition, ...]:
200
+ with sqlite3.connect(self.path) as connection:
201
+ rows = connection.execute(
202
+ """
203
+ SELECT code, name, kind, sort_order, meta_key, origin_address
204
+ FROM areas
205
+ ORDER BY id
206
+ """
207
+ ).fetchall()
208
+ return tuple(
209
+ AreaDefinition(
210
+ code=row[0],
211
+ name=row[1],
212
+ kind=AreaKind(row[2]),
213
+ default_origin=parse_address(row[5]),
214
+ sort_order=row[3],
215
+ meta_key=row[4],
216
+ )
217
+ for row in rows
218
+ )
219
+
220
+
221
+ def connect_database(
222
+ path: Path, *, read_only: bool, timeout: float = 5.0
223
+ ) -> sqlite3.Connection:
224
+ if not path.is_file():
225
+ raise FileNotFoundError(path)
226
+ if read_only:
227
+ connection = sqlite3.connect(
228
+ f"{path.resolve().as_uri()}?mode=ro",
229
+ uri=True,
230
+ timeout=timeout,
231
+ isolation_level=None,
232
+ )
233
+ else:
234
+ connection = sqlite3.connect(path, timeout=timeout, isolation_level=None)
235
+ connection.row_factory = sqlite3.Row
236
+ connection.execute("PRAGMA foreign_keys = ON")
237
+ connection.execute(f"PRAGMA busy_timeout = {round(timeout * 1000)}")
238
+ application_id = connection.execute("PRAGMA application_id").fetchone()[0]
239
+ schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
240
+ if application_id != APPLICATION_ID or schema_version != SCHEMA_VERSION:
241
+ connection.close()
242
+ raise ValueError("not a supported golded-ftn-sqlite database")
243
+ return connection
244
+
245
+
246
+ def select_area(connection: sqlite3.Connection, area_code: str | None) -> sqlite3.Row:
247
+ if area_code is not None:
248
+ row = connection.execute(
249
+ "SELECT * FROM areas WHERE code = ?", (area_code,)
250
+ ).fetchone()
251
+ if row is None:
252
+ raise KeyError(f"unknown area [{area_code}]")
253
+ return cast(sqlite3.Row, row)
254
+ rows = connection.execute("SELECT * FROM areas ORDER BY id LIMIT 2").fetchall()
255
+ if len(rows) != 1:
256
+ raise ValueError("area_code is required unless the database has one area")
257
+ return cast(sqlite3.Row, rows[0])
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+ from golded_ftn import FtnAddress
7
+
8
+
9
+ class AreaKind(StrEnum):
10
+ NETMAIL = "netmail"
11
+ ECHOMAIL = "echomail"
12
+
13
+
14
+ @dataclass(frozen=True, slots=True, kw_only=True)
15
+ class AreaDefinition:
16
+ code: str
17
+ name: str
18
+ kind: AreaKind
19
+ default_origin: FtnAddress | None = None
20
+ sort_order: int = 0
21
+ meta_key: str | None = None
22
+
23
+ def __post_init__(self) -> None:
24
+ if not self.code or self.code != self.code.strip():
25
+ raise ValueError(
26
+ "area code must be non-empty and have no surrounding space"
27
+ )
28
+ if not self.name or self.name != self.name.strip():
29
+ raise ValueError(
30
+ "area name must be non-empty and have no surrounding space"
31
+ )
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from collections.abc import Iterable, Iterator
5
+ from datetime import datetime
6
+ from os import PathLike
7
+ from pathlib import Path
8
+
9
+ from golded_ftn import (
10
+ ControlLine,
11
+ MessageControlLines,
12
+ MessageProvenance,
13
+ ParsedMessage,
14
+ ReaderOptions,
15
+ parse_message,
16
+ )
17
+
18
+ from .database import connect_database, select_area
19
+
20
+
21
+ def _message_from_row(
22
+ connection: sqlite3.Connection,
23
+ database_path: Path,
24
+ area: sqlite3.Row,
25
+ row: sqlite3.Row,
26
+ ) -> ParsedMessage:
27
+ controls = tuple(
28
+ ControlLine(name=item["name"], value=item["value"], raw=item["raw"])
29
+ for item in connection.execute(
30
+ """
31
+ SELECT name, value, raw FROM control_lines
32
+ WHERE message_id = ? ORDER BY position
33
+ """,
34
+ (row["id"],),
35
+ )
36
+ )
37
+ routing = {
38
+ kind: tuple(
39
+ item["value"]
40
+ for item in connection.execute(
41
+ """
42
+ SELECT value FROM routing_entries
43
+ WHERE message_id = ? AND kind = ? ORDER BY position
44
+ """,
45
+ (row["id"], kind),
46
+ )
47
+ )
48
+ for kind in ("seen_by", "path")
49
+ }
50
+ parsed_body = parse_message(row["body_text"])
51
+ by_name: dict[str, str] = {}
52
+ for control in controls:
53
+ by_name.setdefault(control.name.upper(), control.value)
54
+ provenance = (
55
+ MessageProvenance(
56
+ source_type=row["source_type"],
57
+ source_path=row["source_path"],
58
+ source_id=row["source_id"],
59
+ source_offset=row["source_offset"],
60
+ )
61
+ if row["source_type"] is not None
62
+ else MessageProvenance(
63
+ source_type="sqlite",
64
+ source_path=str(database_path),
65
+ source_id=str(row["id"]),
66
+ )
67
+ )
68
+ return ParsedMessage(
69
+ msgno=row["msgno"],
70
+ from_name=row["from_name"],
71
+ to_name=row["to_name"],
72
+ subject=row["subject"],
73
+ body_text=row["body_text"],
74
+ attributes_raw=row["attributes_raw"],
75
+ posted_at=(
76
+ datetime.fromisoformat(row["posted_at"])
77
+ if row["posted_at"] is not None
78
+ else None
79
+ ),
80
+ external_id=row["external_id"],
81
+ from_address=row["from_address"],
82
+ to_address=row["to_address"],
83
+ reply_to_msgno=row["reply_to_msgno"],
84
+ reply1st_msgno=row["reply1st_msgno"],
85
+ reply_next_msgno=row["reply_next_msgno"],
86
+ area_code=area["code"],
87
+ area_name=area["name"],
88
+ area_sort_order=area["sort_order"],
89
+ area_meta_key=area["meta_key"],
90
+ control_lines=MessageControlLines(
91
+ kludges=controls,
92
+ msgid=row["external_id"] or by_name.get("MSGID"),
93
+ reply=by_name.get("REPLY"),
94
+ charset=by_name.get("CHRS") or by_name.get("CHARSET"),
95
+ seen_by=routing["seen_by"],
96
+ path=routing["path"],
97
+ tearline=parsed_body.tearline,
98
+ origin=parsed_body.origin,
99
+ origin_address=parsed_body.origin_address,
100
+ ),
101
+ provenance=provenance,
102
+ )
103
+
104
+
105
+ class SqliteReader:
106
+ """Read undeleted messages for one area in msgno order.
107
+
108
+ Default streaming yields rows from a live cursor. Pass
109
+ ``ReaderOptions(eager=True)`` to load every message before returning.
110
+ """
111
+
112
+ def __init__(self, area_code: str | None = None) -> None:
113
+ self.area_code = area_code
114
+
115
+ def read(
116
+ self,
117
+ path: str | PathLike[str],
118
+ options: ReaderOptions | None = None,
119
+ ) -> Iterable[ParsedMessage]:
120
+ options = options or ReaderOptions()
121
+ database_path = Path(path).resolve()
122
+ connection = connect_database(database_path, read_only=True)
123
+ try:
124
+ connection.execute("BEGIN")
125
+ area = select_area(connection, self.area_code)
126
+ rows = connection.execute(
127
+ """
128
+ SELECT * FROM messages
129
+ WHERE area_id = ? AND deleted_at IS NULL
130
+ ORDER BY msgno
131
+ """,
132
+ (area["id"],),
133
+ )
134
+ if options.eager:
135
+ messages = tuple(
136
+ _message_from_row(connection, database_path, area, row)
137
+ for row in rows.fetchall()
138
+ )
139
+ connection.commit()
140
+ connection.close()
141
+ return messages
142
+ return self._stream(connection, database_path, area, rows)
143
+ except BaseException:
144
+ connection.rollback()
145
+ connection.close()
146
+ raise
147
+
148
+ @staticmethod
149
+ def _stream(
150
+ connection: sqlite3.Connection,
151
+ database_path: Path,
152
+ area: sqlite3.Row,
153
+ rows: sqlite3.Cursor,
154
+ ) -> Iterator[ParsedMessage]:
155
+ try:
156
+ for row in rows:
157
+ yield _message_from_row(connection, database_path, area, row)
158
+ connection.commit()
159
+ except BaseException:
160
+ connection.rollback()
161
+ raise
162
+ finally:
163
+ connection.close()
@@ -0,0 +1,510 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ from datetime import UTC, datetime
7
+ from os import PathLike
8
+ from pathlib import Path
9
+ from types import TracebackType
10
+ from typing import Self
11
+
12
+ from golded_ftn import (
13
+ ConflictError,
14
+ MessageIdentity,
15
+ MessagePatch,
16
+ OutgoingMessage,
17
+ RevisionToken,
18
+ SessionMessage,
19
+ Unset,
20
+ WriterError,
21
+ WriteResult,
22
+ WriterOptions,
23
+ )
24
+
25
+ from .database import connect_database, format_address, parse_address, select_area
26
+ from .reader import _message_from_row
27
+
28
+
29
+ def _identity(path: Path, area_code: str, msgno: int) -> MessageIdentity:
30
+ return MessageIdentity(
31
+ format="sqlite",
32
+ base=f"{path}#{area_code}",
33
+ msgno=msgno,
34
+ )
35
+
36
+
37
+ def _revision(
38
+ connection: sqlite3.Connection,
39
+ row: sqlite3.Row,
40
+ identity: MessageIdentity,
41
+ ) -> RevisionToken:
42
+ revision = int(row["revision"])
43
+ message_id = int(row["id"])
44
+ related = (
45
+ tuple(
46
+ tuple(item)
47
+ for item in connection.execute(
48
+ """
49
+ SELECT position, name, value, raw FROM control_lines
50
+ WHERE message_id = ? ORDER BY position
51
+ """,
52
+ (message_id,),
53
+ )
54
+ ),
55
+ tuple(
56
+ tuple(item)
57
+ for item in connection.execute(
58
+ """
59
+ SELECT kind, position, value FROM routing_entries
60
+ WHERE message_id = ? ORDER BY kind, position
61
+ """,
62
+ (message_id,),
63
+ )
64
+ ),
65
+ tuple(
66
+ tuple(item)
67
+ for item in connection.execute(
68
+ """
69
+ SELECT position, msgno FROM reply_list_entries
70
+ WHERE message_id = ? ORDER BY position
71
+ """,
72
+ (message_id,),
73
+ )
74
+ ),
75
+ )
76
+ digest = hashlib.sha256(
77
+ json.dumps(
78
+ (tuple(row), related), ensure_ascii=False, separators=(",", ":")
79
+ ).encode()
80
+ ).hexdigest()
81
+ return RevisionToken(
82
+ identity=identity,
83
+ location=(message_id, revision),
84
+ digest=digest,
85
+ )
86
+
87
+
88
+ class SqliteWriter:
89
+ def __init__(self, area_code: str | None = None) -> None:
90
+ self.area_code = area_code
91
+
92
+ def open(
93
+ self,
94
+ path: str | PathLike[str],
95
+ options: WriterOptions | None = None,
96
+ ) -> SqliteSession:
97
+ return SqliteSession(
98
+ Path(path).resolve(), self.area_code, options or WriterOptions()
99
+ )
100
+
101
+
102
+ class SqliteSession:
103
+ def __init__(
104
+ self, path: Path, area_code: str | None, options: WriterOptions
105
+ ) -> None:
106
+ self.path = path
107
+ self.area_code = area_code
108
+ self._connection = connect_database(
109
+ path, read_only=False, timeout=options.lock_timeout
110
+ )
111
+ self._closed = False
112
+
113
+ def __enter__(self) -> Self:
114
+ return self
115
+
116
+ def __exit__(
117
+ self,
118
+ exc_type: type[BaseException] | None,
119
+ exc_value: BaseException | None,
120
+ traceback: TracebackType | None,
121
+ ) -> None:
122
+ self.close()
123
+
124
+ def close(self) -> None:
125
+ if not self._closed:
126
+ self._connection.close()
127
+ self._closed = True
128
+
129
+ def append(self, message: OutgoingMessage) -> WriteResult:
130
+ if self._closed:
131
+ raise WriterError("SQLite session is closed")
132
+ connection = self._connection
133
+ try:
134
+ connection.execute("BEGIN IMMEDIATE")
135
+ area = select_area(connection, self.area_code)
136
+ default_origin = parse_address(area["origin_address"])
137
+ from_address = message.from_address or default_origin
138
+ if area["kind"] == "netmail":
139
+ if from_address is None:
140
+ raise ValueError("netmail requires an origin address")
141
+ if message.to_address is None:
142
+ raise ValueError("netmail requires a destination address")
143
+ msgno = int(area["next_message_number"])
144
+ cursor = connection.execute(
145
+ """
146
+ INSERT INTO messages (
147
+ area_id, msgno, from_name, to_name, subject, body_text,
148
+ posted_at, external_id, attributes_raw,
149
+ from_address, to_address,
150
+ reply_to_msgno, reply1st_msgno, reply_next_msgno,
151
+ source_type, source_path, source_id, source_offset
152
+ ) VALUES (
153
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
154
+ )
155
+ """,
156
+ (
157
+ area["id"],
158
+ msgno,
159
+ message.from_name,
160
+ message.to_name,
161
+ message.subject,
162
+ message.body_text,
163
+ message.posted_at.isoformat() if message.posted_at else None,
164
+ message.external_id,
165
+ message.attributes_raw if message.attributes_raw is not None else 0,
166
+ format_address(from_address),
167
+ format_address(message.to_address),
168
+ message.reply_to_msgno,
169
+ message.reply1st_msgno,
170
+ message.reply_next_msgno,
171
+ message.provenance.source_type if message.provenance else None,
172
+ message.provenance.source_path if message.provenance else None,
173
+ message.provenance.source_id if message.provenance else None,
174
+ message.provenance.source_offset if message.provenance else None,
175
+ ),
176
+ )
177
+ if cursor.lastrowid is None:
178
+ raise WriterError("SQLite did not return a message row ID")
179
+ message_id = cursor.lastrowid
180
+ connection.executemany(
181
+ """
182
+ INSERT INTO control_lines (message_id, position, name, value, raw)
183
+ VALUES (?, ?, ?, ?, ?)
184
+ """,
185
+ (
186
+ (message_id, position, line.name, line.value, line.raw)
187
+ for position, line in enumerate(message.control_lines)
188
+ ),
189
+ )
190
+ connection.executemany(
191
+ """
192
+ INSERT INTO routing_entries (message_id, kind, position, value)
193
+ VALUES (?, 'seen_by', ?, ?)
194
+ """,
195
+ (
196
+ (message_id, position, value)
197
+ for position, value in enumerate(message.routing_seen_by)
198
+ ),
199
+ )
200
+ connection.executemany(
201
+ """
202
+ INSERT INTO routing_entries (message_id, kind, position, value)
203
+ VALUES (?, 'path', ?, ?)
204
+ """,
205
+ (
206
+ (message_id, position, value)
207
+ for position, value in enumerate(message.routing_path)
208
+ ),
209
+ )
210
+ connection.executemany(
211
+ """
212
+ INSERT INTO reply_list_entries (message_id, position, msgno)
213
+ VALUES (?, ?, ?)
214
+ """,
215
+ (
216
+ (message_id, position, value)
217
+ for position, value in enumerate(message.reply_list)
218
+ ),
219
+ )
220
+ connection.execute(
221
+ "UPDATE areas SET next_message_number = ? WHERE id = ?",
222
+ (msgno + 1, area["id"]),
223
+ )
224
+ stored = connection.execute(
225
+ "SELECT * FROM messages WHERE id = ?", (message_id,)
226
+ ).fetchone()
227
+ assert stored is not None
228
+ identity = _identity(self.path, area["code"], msgno)
229
+ revision = _revision(connection, stored, identity)
230
+ connection.commit()
231
+ return WriteResult(
232
+ identity=identity,
233
+ revision=revision,
234
+ )
235
+ except BaseException:
236
+ connection.rollback()
237
+ raise
238
+
239
+ def read(self, msgno: int) -> SessionMessage:
240
+ if self._closed:
241
+ raise WriterError("SQLite session is closed")
242
+ connection = self._connection
243
+ try:
244
+ connection.execute("BEGIN")
245
+ area = select_area(connection, self.area_code)
246
+ row = connection.execute(
247
+ """
248
+ SELECT * FROM messages
249
+ WHERE area_id = ? AND msgno = ? AND deleted_at IS NULL
250
+ """,
251
+ (area["id"], msgno),
252
+ ).fetchone()
253
+ if row is None:
254
+ raise ConflictError("SQLite message is missing")
255
+ identity = _identity(self.path, area["code"], msgno)
256
+ result = SessionMessage(
257
+ message=_message_from_row(connection, self.path, area, row),
258
+ identity=identity,
259
+ revision=_revision(connection, row, identity),
260
+ )
261
+ connection.commit()
262
+ return result
263
+ except BaseException:
264
+ connection.rollback()
265
+ raise
266
+
267
+ def update(
268
+ self,
269
+ identity: MessageIdentity,
270
+ patch: MessagePatch,
271
+ expected_revision: RevisionToken,
272
+ ) -> WriteResult:
273
+ if self._closed:
274
+ raise WriterError("SQLite session is closed")
275
+ connection = self._connection
276
+ try:
277
+ connection.execute("BEGIN IMMEDIATE")
278
+ area = select_area(connection, self.area_code)
279
+ row = connection.execute(
280
+ """
281
+ SELECT * FROM messages
282
+ WHERE area_id = ? AND msgno = ? AND deleted_at IS NULL
283
+ """,
284
+ (area["id"], identity.msgno),
285
+ ).fetchone()
286
+ if row is None:
287
+ raise ConflictError("SQLite message is missing")
288
+ actual_identity = _identity(self.path, area["code"], identity.msgno)
289
+ actual_revision = _revision(connection, row, actual_identity)
290
+ if (
291
+ identity != actual_identity
292
+ or expected_revision.identity != actual_identity
293
+ or expected_revision != actual_revision
294
+ ):
295
+ raise ConflictError("SQLite message revision changed")
296
+ from_name = (
297
+ row["from_name"]
298
+ if isinstance(patch.from_name, Unset)
299
+ else patch.from_name
300
+ )
301
+ to_name = (
302
+ row["to_name"] if isinstance(patch.to_name, Unset) else patch.to_name
303
+ )
304
+ subject = (
305
+ row["subject"] if isinstance(patch.subject, Unset) else patch.subject
306
+ )
307
+ body_text = (
308
+ row["body_text"]
309
+ if isinstance(patch.body_text, Unset)
310
+ else patch.body_text
311
+ )
312
+ if None in (from_name, to_name, subject, body_text):
313
+ raise ValueError("names, subject and body_text cannot be None")
314
+ external_id = (
315
+ row["external_id"]
316
+ if isinstance(patch.external_id, Unset)
317
+ else patch.external_id
318
+ )
319
+ from_address = (
320
+ parse_address(row["from_address"])
321
+ if isinstance(patch.from_address, Unset)
322
+ else patch.from_address
323
+ )
324
+ to_address = (
325
+ parse_address(row["to_address"])
326
+ if isinstance(patch.to_address, Unset)
327
+ else patch.to_address
328
+ )
329
+ if isinstance(patch.posted_at, Unset):
330
+ posted_at = row["posted_at"]
331
+ elif patch.posted_at is None:
332
+ posted_at = None
333
+ else:
334
+ posted_at = patch.posted_at.isoformat()
335
+ attributes_raw = (
336
+ row["attributes_raw"]
337
+ if isinstance(patch.attributes_raw, Unset)
338
+ else patch.attributes_raw
339
+ )
340
+ if attributes_raw is None:
341
+ raise ValueError("attributes_raw cannot be None")
342
+ reply_to_msgno = (
343
+ row["reply_to_msgno"]
344
+ if isinstance(patch.reply_to_msgno, Unset)
345
+ else patch.reply_to_msgno
346
+ )
347
+ reply1st_msgno = (
348
+ row["reply1st_msgno"]
349
+ if isinstance(patch.reply1st_msgno, Unset)
350
+ else patch.reply1st_msgno
351
+ )
352
+ reply_next_msgno = (
353
+ row["reply_next_msgno"]
354
+ if isinstance(patch.reply_next_msgno, Unset)
355
+ else patch.reply_next_msgno
356
+ )
357
+ if isinstance(patch.provenance, Unset):
358
+ provenance_values = (
359
+ row["source_type"],
360
+ row["source_path"],
361
+ row["source_id"],
362
+ row["source_offset"],
363
+ )
364
+ elif patch.provenance is None:
365
+ provenance_values = (None, None, None, None)
366
+ else:
367
+ provenance_values = (
368
+ patch.provenance.source_type,
369
+ patch.provenance.source_path,
370
+ patch.provenance.source_id,
371
+ patch.provenance.source_offset,
372
+ )
373
+ if area["kind"] == "netmail":
374
+ if from_address is None:
375
+ raise ValueError("netmail requires an origin address")
376
+ if to_address is None:
377
+ raise ValueError("netmail requires a destination address")
378
+ connection.execute(
379
+ """
380
+ UPDATE messages SET
381
+ from_name = ?, to_name = ?, subject = ?, body_text = ?,
382
+ posted_at = ?, external_id = ?, attributes_raw = ?,
383
+ from_address = ?, to_address = ?,
384
+ reply_to_msgno = ?, reply1st_msgno = ?, reply_next_msgno = ?,
385
+ source_type = ?, source_path = ?, source_id = ?, source_offset = ?,
386
+ revision = revision + 1
387
+ WHERE id = ?
388
+ """,
389
+ (
390
+ from_name,
391
+ to_name,
392
+ subject,
393
+ body_text,
394
+ posted_at,
395
+ external_id,
396
+ attributes_raw,
397
+ format_address(from_address),
398
+ format_address(to_address),
399
+ reply_to_msgno,
400
+ reply1st_msgno,
401
+ reply_next_msgno,
402
+ *provenance_values,
403
+ row["id"],
404
+ ),
405
+ )
406
+ if not isinstance(patch.control_lines, Unset):
407
+ connection.execute(
408
+ "DELETE FROM control_lines WHERE message_id = ?", (row["id"],)
409
+ )
410
+ connection.executemany(
411
+ """
412
+ INSERT INTO control_lines
413
+ (message_id, position, name, value, raw)
414
+ VALUES (?, ?, ?, ?, ?)
415
+ """,
416
+ (
417
+ (row["id"], position, line.name, line.value, line.raw)
418
+ for position, line in enumerate(patch.control_lines or ())
419
+ ),
420
+ )
421
+ for kind, values in (
422
+ ("seen_by", patch.routing_seen_by),
423
+ ("path", patch.routing_path),
424
+ ):
425
+ if isinstance(values, Unset):
426
+ continue
427
+ connection.execute(
428
+ "DELETE FROM routing_entries WHERE message_id = ? AND kind = ?",
429
+ (row["id"], kind),
430
+ )
431
+ connection.executemany(
432
+ """
433
+ INSERT INTO routing_entries (message_id, kind, position, value)
434
+ VALUES (?, ?, ?, ?)
435
+ """,
436
+ (
437
+ (row["id"], kind, position, value)
438
+ for position, value in enumerate(values or ())
439
+ ),
440
+ )
441
+ if not isinstance(patch.reply_list, Unset):
442
+ connection.execute(
443
+ "DELETE FROM reply_list_entries WHERE message_id = ?",
444
+ (row["id"],),
445
+ )
446
+ connection.executemany(
447
+ """
448
+ INSERT INTO reply_list_entries (message_id, position, msgno)
449
+ VALUES (?, ?, ?)
450
+ """,
451
+ (
452
+ (row["id"], position, msgno)
453
+ for position, msgno in enumerate(patch.reply_list or ())
454
+ ),
455
+ )
456
+ stored = connection.execute(
457
+ "SELECT * FROM messages WHERE id = ?", (row["id"],)
458
+ ).fetchone()
459
+ assert stored is not None
460
+ revision = _revision(connection, stored, actual_identity)
461
+ connection.commit()
462
+ return WriteResult(
463
+ identity=actual_identity,
464
+ revision=revision,
465
+ )
466
+ except BaseException:
467
+ connection.rollback()
468
+ raise
469
+
470
+ def delete(
471
+ self,
472
+ identity: MessageIdentity,
473
+ expected_revision: RevisionToken,
474
+ ) -> MessageIdentity:
475
+ if self._closed:
476
+ raise WriterError("SQLite session is closed")
477
+ connection = self._connection
478
+ try:
479
+ connection.execute("BEGIN IMMEDIATE")
480
+ area = select_area(connection, self.area_code)
481
+ row = connection.execute(
482
+ """
483
+ SELECT * FROM messages
484
+ WHERE area_id = ? AND msgno = ? AND deleted_at IS NULL
485
+ """,
486
+ (area["id"], identity.msgno),
487
+ ).fetchone()
488
+ if row is None:
489
+ raise ConflictError("SQLite message is missing")
490
+ actual_identity = _identity(self.path, area["code"], identity.msgno)
491
+ actual_revision = _revision(connection, row, actual_identity)
492
+ if (
493
+ identity != actual_identity
494
+ or expected_revision.identity != actual_identity
495
+ or expected_revision != actual_revision
496
+ ):
497
+ raise ConflictError("SQLite message revision changed")
498
+ connection.execute(
499
+ """
500
+ UPDATE messages
501
+ SET deleted_at = ?, revision = revision + 1
502
+ WHERE id = ?
503
+ """,
504
+ (datetime.now(UTC).isoformat(), row["id"]),
505
+ )
506
+ connection.commit()
507
+ return actual_identity
508
+ except BaseException:
509
+ connection.rollback()
510
+ raise
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: golded-ftn-sqlite
3
+ Version: 1.0.0
4
+ Summary: Transactional SQLite message bases for FTN netmail and echomail
5
+ Project-URL: Repository, https://github.com/golded-dev/golded-ftn-sqlite-python
6
+ Project-URL: Issues, https://github.com/golded-dev/golded-ftn-sqlite-python/issues
7
+ Project-URL: Changelog, https://github.com/golded-dev/golded-ftn-sqlite-python/blob/main/CHANGELOG.md
8
+ Author: GoldED.dev contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: golded-ftn<2,>=1.4.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # golded-ftn-sqlite
22
+
23
+ Repository: [`golded-ftn-sqlite-python`](https://github.com/golded-dev/golded-ftn-sqlite-python).
24
+ Install version 1.0.0 with Python 3.12 or newer:
25
+
26
+ ```sh
27
+ python -m pip install golded-ftn-sqlite==1.0.0
28
+ ```
29
+
30
+ `golded-ftn-sqlite` stores FTN netmail and echomail in SQLite through the
31
+ [`golded-ftn`](https://github.com/golded-dev/golded-ftn-python) message models.
32
+ It requires Python 3.12 or newer.
33
+
34
+ The format supports several areas in one database. The ordinary layout is still
35
+ one database file per area: it maps cleanly to GoldED configuration, keeps backup
36
+ and recovery local, and limits the blast radius when a disk has opinions.
37
+
38
+ ## Pipe with ftnt
39
+
40
+ `ftnt` treats export envelopes as write input for every format, including SQLite:
41
+
42
+ ```sh
43
+ ftnt create ./general.sqlite3 --format sqlite --area-code FIDO.GENERAL
44
+ ftnt export ./general --format jam |
45
+ ftnt write ./general.sqlite3 --format sqlite --jsonl
46
+ ```
47
+
48
+ ## Use it
49
+
50
+ ```python
51
+ from golded_ftn import FtnAddress, OutgoingMessage
52
+ from golded_ftn_sqlite import (
53
+ AreaDefinition,
54
+ AreaKind,
55
+ SqliteDatabase,
56
+ SqliteReader,
57
+ SqliteWriter,
58
+ )
59
+
60
+ path = "fido-general.sqlite3"
61
+ SqliteDatabase.create(
62
+ path,
63
+ area=AreaDefinition(
64
+ code="FIDO.GENERAL",
65
+ name="FidoNet General",
66
+ kind=AreaKind.ECHOMAIL,
67
+ default_origin=FtnAddress(zone=2, net=230, node=0),
68
+ ),
69
+ )
70
+
71
+ with SqliteWriter().open(path) as session:
72
+ result = session.append(
73
+ OutgoingMessage(
74
+ from_name="Alice",
75
+ to_name="All",
76
+ subject="Hello",
77
+ body_text="Stored transactionally.",
78
+ )
79
+ )
80
+ stored = session.read(result.identity.msgno)
81
+
82
+ messages = tuple(SqliteReader().read(path))
83
+ ```
84
+
85
+ When a database contains several areas, pass the area code to `SqliteReader` and
86
+ `SqliteWriter`. Omitting it is allowed only for a single-area database. Message
87
+ numbers are monotonic and local to each area.
88
+
89
+ Netmail requires origin and destination addresses. The origin may come from the
90
+ area's `default_origin`; the destination is always explicit. Echomail keeps ordered
91
+ control lines, repeated unknown kludges, `SEEN-BY`, `PATH`, reply links and import
92
+ provenance.
93
+
94
+ Writer sessions provide `read`, `append`, `update` and soft `delete`. Updates and
95
+ deletes require a revision token. The token covers the message row and its ordered
96
+ control, routing and reply-list rows, including changes made by another SQLite
97
+ client.
98
+
99
+ ## SQLite format
100
+
101
+ Schema version 1 uses SQLite application ID `0x47465453` (`GFTS`), strict tables,
102
+ foreign keys and WAL journaling. The schema is a public format contract:
103
+ [docs/schema.md](docs/schema.md) documents identification, DDL, column semantics,
104
+ allocation, soft delete, concurrency and backup so other packages or applications
105
+ can implement the same database without this Python code.
106
+
107
+ Do not copy an open WAL database as a lone `.sqlite3` file. Use SQLite's backup API
108
+ or close/checkpoint it first.
109
+
110
+ ## Development
111
+
112
+ ```sh
113
+ uv sync --python 3.14
114
+ uv run pytest
115
+ uv run ruff check .
116
+ uv run ruff format --check .
117
+ uv run mypy
118
+ uv run python -m mypy.stubtest golded_ftn_sqlite
119
+ uv build
120
+ uv run twine check dist/*
121
+ uv run python scripts/verify_distribution.py
122
+ ```
123
+
@@ -0,0 +1,10 @@
1
+ golded_ftn_sqlite/__init__.py,sha256=VvxVd-MmlJ9otYAl8-2OkrOvt3xI1DCVt7eCM5PUSJY,357
2
+ golded_ftn_sqlite/database.py,sha256=4el8OCOITB1RHulX08fsNLBzFyqMb58zQYigr-C1CWM,8532
3
+ golded_ftn_sqlite/models.py,sha256=FWNcvS6dgGxFStuCsUwVVvaR09hllSckK2HYxQFfK3g,827
4
+ golded_ftn_sqlite/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ golded_ftn_sqlite/reader.py,sha256=qlTmSF8Cs-PVdSDPo6g-zH8nSanXyfd1C5sVOxIL0qw,5092
6
+ golded_ftn_sqlite/writer.py,sha256=BwV_wzFuxd5lJnuUWelP5hHbAgYDI4q2VgIO3NFlw5U,18759
7
+ golded_ftn_sqlite-1.0.0.dist-info/METADATA,sha256=6-zishzsePO-kpPsPtvJcq3YE66Jj7sDy5nq1jkp_e0,4064
8
+ golded_ftn_sqlite-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ golded_ftn_sqlite-1.0.0.dist-info/licenses/LICENSE,sha256=MEbLGdaXThJPkf6b701rRnfChrYlR9AVpnpXRpbprk4,1080
10
+ golded_ftn_sqlite-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GoldED.dev contributors
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.