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.
- golded_ftn_sqlite/__init__.py +15 -0
- golded_ftn_sqlite/database.py +257 -0
- golded_ftn_sqlite/models.py +31 -0
- golded_ftn_sqlite/py.typed +1 -0
- golded_ftn_sqlite/reader.py +163 -0
- golded_ftn_sqlite/writer.py +510 -0
- golded_ftn_sqlite-1.0.0.dist-info/METADATA +123 -0
- golded_ftn_sqlite-1.0.0.dist-info/RECORD +10 -0
- golded_ftn_sqlite-1.0.0.dist-info/WHEEL +4 -0
- golded_ftn_sqlite-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|