tt-data-store 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.
@@ -0,0 +1,14 @@
1
+ """Canonical market data archive library."""
2
+
3
+ from tt_data_store.store import MarketStore
4
+ from tt_data_store.remote import RemoteConfig, SyncConflict, TransferResult
5
+ from tt_data_store.validation import ValidationError, ValidationResult
6
+
7
+ __all__ = [
8
+ "MarketStore",
9
+ "RemoteConfig",
10
+ "SyncConflict",
11
+ "TransferResult",
12
+ "ValidationError",
13
+ "ValidationResult",
14
+ ]
@@ -0,0 +1,22 @@
1
+ """Atomic Parquet replacement (doc 06)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def atomic_replace(temp_path: Path, target_path: Path) -> None:
10
+ """Replace target with temp via sync + os.replace.
11
+
12
+ Temporary files live beside the target as ``.{name}.tmp`` (doc 06).
13
+ """
14
+ target_path.parent.mkdir(parents=True, exist_ok=True)
15
+ with open(temp_path, "rb") as f:
16
+ f.flush()
17
+ os.fsync(f.fileno())
18
+ os.replace(temp_path, target_path)
19
+
20
+
21
+ def temp_path_for(target_path: Path) -> Path:
22
+ return target_path.parent / f".{target_path.name}.tmp"
@@ -0,0 +1,98 @@
1
+ """Market calendar sessions → expected timestamps (doc 08)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime, timedelta
6
+ from pathlib import Path
7
+ from zoneinfo import ZoneInfo
8
+
9
+ import pyarrow as pa
10
+ import pyarrow.parquet as pq
11
+
12
+ from tt_data_store.schema import TZ
13
+
14
+ IST = ZoneInfo(TZ)
15
+
16
+ CALENDAR_SCHEMA = pa.schema(
17
+ [
18
+ pa.field("date", pa.date32(), nullable=False),
19
+ pa.field("market", pa.string(), nullable=False),
20
+ pa.field("session_start", pa.timestamp("us", tz=TZ), nullable=True),
21
+ pa.field("session_end", pa.timestamp("us", tz=TZ), nullable=True),
22
+ pa.field("event", pa.string(), nullable=True),
23
+ ]
24
+ )
25
+
26
+
27
+ def calendar_path(root: Path, market: str = "nse") -> Path:
28
+ return root / "reference" / "calendar" / f"{market.lower()}.parquet"
29
+
30
+
31
+ def load_sessions(
32
+ root: Path,
33
+ *,
34
+ market: str = "NSE",
35
+ start: date | None = None,
36
+ end: date | None = None,
37
+ ) -> list[tuple[datetime, datetime]]:
38
+ path = calendar_path(root, market="nse" if market.upper() == "NSE" else market.lower())
39
+ if not path.exists():
40
+ return []
41
+ table = pq.read_table(path)
42
+ sessions: list[tuple[datetime, datetime]] = []
43
+ for i in range(table.num_rows):
44
+ mkt = table.column("market")[i].as_py()
45
+ if str(mkt).upper() != market.upper():
46
+ continue
47
+ d = table.column("date")[i].as_py()
48
+ if start is not None and d < start:
49
+ continue
50
+ if end is not None and d > end:
51
+ continue
52
+ s_start = table.column("session_start")[i].as_py()
53
+ s_end = table.column("session_end")[i].as_py()
54
+ if s_start is None or s_end is None:
55
+ # Holiday / non-trading row: no expected timestamps
56
+ continue
57
+ if s_start.tzinfo is None:
58
+ s_start = s_start.replace(tzinfo=IST)
59
+ else:
60
+ s_start = s_start.astimezone(IST)
61
+ if s_end.tzinfo is None:
62
+ s_end = s_end.replace(tzinfo=IST)
63
+ else:
64
+ s_end = s_end.astimezone(IST)
65
+ sessions.append((s_start, s_end))
66
+ return sessions
67
+
68
+
69
+ def expected_timestamps(
70
+ sessions: list[tuple[datetime, datetime]],
71
+ ) -> list[datetime]:
72
+ """Generate 1-minute timestamps for half-open session [start, end).
73
+
74
+ Candles use timestamp = start of the minute (doc 02). If the market closes
75
+ at ``session_end`` (e.g. 15:30), the last expected bar is ``session_end - 1m``
76
+ (e.g. 15:29). ``session_end`` itself is not an expected candle timestamp.
77
+ """
78
+ out: list[datetime] = []
79
+ for start, end in sessions:
80
+ ts = start
81
+ while ts < end:
82
+ out.append(ts)
83
+ ts += timedelta(minutes=1)
84
+ return out
85
+
86
+
87
+ def write_calendar(
88
+ root: Path,
89
+ rows: list[dict],
90
+ *,
91
+ market_file: str = "nse",
92
+ ) -> Path:
93
+ """Seed reference calendar (used by tests; not a public MarketStore write API)."""
94
+ path = calendar_path(root, market=market_file)
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ table = pa.Table.from_pylist(rows, schema=CALENDAR_SCHEMA)
97
+ pq.write_table(table, path)
98
+ return path
@@ -0,0 +1,523 @@
1
+ """Candle read/write pipeline (docs 04, 06, 10)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from typing import TYPE_CHECKING, Any
8
+ from zoneinfo import ZoneInfo
9
+
10
+ import pyarrow as pa
11
+ import pyarrow.compute as pc
12
+
13
+ from tt_data_store.atomic import atomic_replace, temp_path_for
14
+ from tt_data_store.gaps import serialize_gaps
15
+ from tt_data_store.instruments import Instrument
16
+ from tt_data_store.paths import relative_path_for_instrument, years_in_range
17
+ from tt_data_store.parquet import empty_candles, file_sha256, read_candles, write_candles
18
+ from tt_data_store.schema import SCHEMA_VERSION, TZ
19
+ from tt_data_store.validation import (
20
+ VALIDATOR_VERSION,
21
+ ValidationError,
22
+ assess_quality,
23
+ dedupe_exact,
24
+ filter_to_session,
25
+ merge_tables,
26
+ normalize,
27
+ validate_hard,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from tt_data_store.store import MarketStore
32
+
33
+ IST = ZoneInfo(TZ)
34
+
35
+
36
+ @dataclass
37
+ class WriteResult:
38
+ instrument: str
39
+ rows_received: int
40
+ rows_inserted: int
41
+ rows_duplicate: int
42
+ rows_conflicted: int
43
+ rows_rejected: int
44
+ previous_rows: int
45
+ new_rows: int
46
+ previous_gaps: list[list[str]] | None
47
+ new_gaps: list[list[str]] | None
48
+ status: str
49
+ file_changed: bool
50
+ path: str
51
+
52
+
53
+ def _now() -> str:
54
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+
56
+
57
+ def _resolve_instrument(store: MarketStore, instrument: Instrument | str) -> Instrument:
58
+ if isinstance(instrument, Instrument):
59
+ return instrument
60
+ found = store.instruments.get(instrument)
61
+ if found is None:
62
+ raise KeyError(f"unknown instrument: {instrument}")
63
+ return found
64
+
65
+
66
+ def _paths_for_table(instrument: Instrument, table: pa.Table) -> dict[str, pa.Table]:
67
+ """Split a candle table into relative_path → subtable."""
68
+ parsed = instrument.parsed
69
+ if parsed.instrument_type != "INDEX":
70
+ path = relative_path_for_instrument(parsed)
71
+ return {path: table}
72
+
73
+ if table.num_rows == 0:
74
+ return {}
75
+
76
+ timestamps = table.column("timestamp").to_pylist()
77
+ by_year: dict[int, list[int]] = {}
78
+ for i, ts in enumerate(timestamps):
79
+ year = ts.astimezone(IST).year
80
+ by_year.setdefault(year, []).append(i)
81
+
82
+ out: dict[str, pa.Table] = {}
83
+ for year, indices in by_year.items():
84
+ path = relative_path_for_instrument(parsed, year=year)
85
+ out[path] = table.take(pa.array(indices))
86
+ return out
87
+
88
+
89
+ class CandlesAPI:
90
+ def __init__(self, store: MarketStore) -> None:
91
+ self._store = store
92
+
93
+ def read(
94
+ self,
95
+ instrument: Instrument | str,
96
+ start: str | datetime | None = None,
97
+ end: str | datetime | None = None,
98
+ ) -> pa.Table:
99
+ inst = _resolve_instrument(self._store, instrument)
100
+ parsed = inst.parsed
101
+ tables: list[pa.Table] = []
102
+
103
+ if parsed.instrument_type == "INDEX":
104
+ if start is None or end is None:
105
+ # Read all known files for this instrument
106
+ files = self._store.files.list(instrument_id=inst.id)
107
+ paths = [f.path for f in files]
108
+ else:
109
+ paths = [
110
+ relative_path_for_instrument(parsed, year=y)
111
+ for y in years_in_range(start, end)
112
+ ]
113
+ else:
114
+ paths = [relative_path_for_instrument(parsed)]
115
+
116
+ for rel in paths:
117
+ abs_path = self._store.root / rel
118
+ if abs_path.exists():
119
+ tables.append(read_candles(abs_path))
120
+
121
+ if not tables:
122
+ return empty_candles()
123
+
124
+ combined = pa.concat_tables(tables)
125
+ if start is not None:
126
+ start_ts = _parse_bound(start, end=False)
127
+ combined = combined.filter(
128
+ pc.greater_equal(combined.column("timestamp"), start_ts)
129
+ )
130
+ if end is not None:
131
+ end_ts = _parse_bound(end, end=True)
132
+ combined = combined.filter(
133
+ pc.less_equal(combined.column("timestamp"), end_ts)
134
+ )
135
+ if combined.num_rows == 0:
136
+ return empty_candles()
137
+ # Ensure sorted
138
+ indices = pc.sort_indices(combined, sort_keys=[("timestamp", "ascending")])
139
+ return combined.take(indices)
140
+
141
+ def write(
142
+ self,
143
+ instrument: Instrument | str,
144
+ candles: pa.Table | list[dict[str, Any]],
145
+ *,
146
+ source: str,
147
+ source_instrument_id: str | None = None,
148
+ collector_version: str | None = None,
149
+ requested_start: str | None = None,
150
+ requested_end: str | None = None,
151
+ ) -> WriteResult:
152
+ inst = _resolve_instrument(self._store, instrument)
153
+ started_at = _now()
154
+ ingestion_id = self._insert_ingestion(
155
+ source=source,
156
+ source_instrument_id=source_instrument_id,
157
+ collector_version=collector_version,
158
+ requested_start=requested_start,
159
+ requested_end=requested_end,
160
+ started_at=started_at,
161
+ status="RUNNING",
162
+ )
163
+
164
+ try:
165
+ if isinstance(candles, list):
166
+ table = pa.Table.from_pylist(candles)
167
+ else:
168
+ table = candles
169
+
170
+ rows_received = table.num_rows
171
+ table = normalize(table)
172
+ table, batch_dups = dedupe_exact(table)
173
+ validate_hard(table)
174
+
175
+ # Partition by target file
176
+ partitions = _paths_for_table(inst, table)
177
+ if not partitions:
178
+ self._finish_ingestion(
179
+ ingestion_id,
180
+ received=rows_received,
181
+ accepted=0,
182
+ rejected=0,
183
+ status="COMPLETED",
184
+ )
185
+ path = relative_path_for_instrument(inst.parsed) if inst.parsed.instrument_type != "INDEX" else ""
186
+ return WriteResult(
187
+ instrument=inst.instrument_key,
188
+ rows_received=rows_received,
189
+ rows_inserted=0,
190
+ rows_duplicate=batch_dups,
191
+ rows_conflicted=0,
192
+ rows_rejected=0,
193
+ previous_rows=0,
194
+ new_rows=0,
195
+ previous_gaps=None,
196
+ new_gaps=None,
197
+ status="UNKNOWN",
198
+ file_changed=False,
199
+ path=path,
200
+ )
201
+
202
+ # For multi-year INDEX writes, process each partition; return last/aggregate
203
+ total_inserted = 0
204
+ total_dup = batch_dups
205
+ total_rejected = 0
206
+ last_result: WriteResult | None = None
207
+
208
+ for rel_path, part in partitions.items():
209
+ result = self._write_partition(
210
+ inst=inst,
211
+ rel_path=rel_path,
212
+ incoming=part,
213
+ rows_received=part.num_rows,
214
+ batch_dups=0,
215
+ )
216
+ total_inserted += result.rows_inserted
217
+ total_dup += result.rows_duplicate
218
+ total_rejected += result.rows_rejected
219
+ last_result = result
220
+
221
+ assert last_result is not None
222
+ self._finish_ingestion(
223
+ ingestion_id,
224
+ received=rows_received,
225
+ accepted=total_inserted,
226
+ rejected=total_rejected,
227
+ status="COMPLETED",
228
+ )
229
+ return WriteResult(
230
+ instrument=inst.instrument_key,
231
+ rows_received=rows_received,
232
+ rows_inserted=total_inserted,
233
+ rows_duplicate=total_dup,
234
+ rows_conflicted=0,
235
+ rows_rejected=total_rejected,
236
+ previous_rows=last_result.previous_rows,
237
+ new_rows=last_result.new_rows,
238
+ previous_gaps=last_result.previous_gaps,
239
+ new_gaps=last_result.new_gaps,
240
+ status=last_result.status,
241
+ file_changed=last_result.file_changed,
242
+ path=last_result.path,
243
+ )
244
+ except ValidationError:
245
+ self._finish_ingestion(
246
+ ingestion_id,
247
+ received=0,
248
+ accepted=0,
249
+ rejected=0,
250
+ status="FAILED",
251
+ )
252
+ raise
253
+ except Exception:
254
+ self._finish_ingestion(
255
+ ingestion_id,
256
+ received=0,
257
+ accepted=0,
258
+ rejected=0,
259
+ status="FAILED",
260
+ )
261
+ raise
262
+
263
+ def _write_partition(
264
+ self,
265
+ *,
266
+ inst: Instrument,
267
+ rel_path: str,
268
+ incoming: pa.Table,
269
+ rows_received: int,
270
+ batch_dups: int,
271
+ ) -> WriteResult:
272
+ abs_path = self._store.root / rel_path
273
+ existing = read_candles(abs_path) if abs_path.exists() else empty_candles()
274
+ previous_rows = existing.num_rows
275
+
276
+ prev_meta = self._store.files.get(rel_path)
277
+ previous_gaps = prev_meta.gaps if prev_meta else None
278
+
279
+ try:
280
+ merged, inserted, duplicates = merge_tables(existing, incoming)
281
+ except ValidationError:
282
+ # Conflict: existing untouched
283
+ raise
284
+
285
+ validate_hard(merged)
286
+
287
+ # Drop out-of-session rows from canonical file when calendar exists
288
+ filtered, rejected = filter_to_session(
289
+ self._store.root, merged, exchange=inst.exchange
290
+ )
291
+ if rejected:
292
+ merged = filtered
293
+ validate_hard(merged)
294
+
295
+ quality = assess_quality(self._store.root, merged, exchange=inst.exchange)
296
+
297
+ file_changed = inserted > 0 or not abs_path.exists() or rejected > 0
298
+ # Also change if first write of empty→empty? no
299
+ if inserted == 0 and duplicates == rows_received and abs_path.exists() and rejected == 0:
300
+ file_changed = False
301
+
302
+ if file_changed or not abs_path.exists():
303
+ tmp = temp_path_for(abs_path)
304
+ try:
305
+ write_candles(tmp, merged)
306
+ atomic_replace(tmp, abs_path)
307
+ finally:
308
+ if tmp.exists():
309
+ tmp.unlink(missing_ok=True)
310
+
311
+ checksum = file_sha256(abs_path)
312
+ size_bytes = abs_path.stat().st_size
313
+ start_time = end_time = None
314
+ if merged.num_rows:
315
+ ts = merged.column("timestamp").to_pylist()
316
+ start_time = ts[0].astimezone(IST).isoformat()
317
+ end_time = ts[-1].astimezone(IST).isoformat()
318
+
319
+ self._upsert_file(
320
+ instrument_id=inst.id,
321
+ path=rel_path,
322
+ start_time=start_time,
323
+ end_time=end_time,
324
+ quality=quality,
325
+ size_bytes=size_bytes,
326
+ checksum=checksum,
327
+ )
328
+ elif prev_meta is None and abs_path.exists():
329
+ # Register existing file metadata after no-op merge on first catalogue miss
330
+ checksum = file_sha256(abs_path)
331
+ size_bytes = abs_path.stat().st_size
332
+ start_time = end_time = None
333
+ if merged.num_rows:
334
+ ts = merged.column("timestamp").to_pylist()
335
+ start_time = ts[0].astimezone(IST).isoformat()
336
+ end_time = ts[-1].astimezone(IST).isoformat()
337
+ self._upsert_file(
338
+ instrument_id=inst.id,
339
+ path=rel_path,
340
+ start_time=start_time,
341
+ end_time=end_time,
342
+ quality=quality,
343
+ size_bytes=size_bytes,
344
+ checksum=checksum,
345
+ )
346
+
347
+ return WriteResult(
348
+ instrument=inst.instrument_key,
349
+ rows_received=rows_received,
350
+ rows_inserted=inserted,
351
+ rows_duplicate=duplicates + batch_dups,
352
+ rows_conflicted=0,
353
+ rows_rejected=rejected,
354
+ previous_rows=previous_rows,
355
+ new_rows=merged.num_rows,
356
+ previous_gaps=previous_gaps,
357
+ new_gaps=quality.gaps,
358
+ status=quality.status,
359
+ file_changed=file_changed,
360
+ path=rel_path,
361
+ )
362
+
363
+ def _upsert_file(
364
+ self,
365
+ *,
366
+ instrument_id: int,
367
+ path: str,
368
+ start_time: str | None,
369
+ end_time: str | None,
370
+ quality: Any,
371
+ size_bytes: int,
372
+ checksum: str,
373
+ ) -> None:
374
+ now = _now()
375
+ gaps_raw = serialize_gaps(quality.gaps)
376
+ existing = self._store.files.get(path)
377
+ conn = self._store.conn
378
+ if existing is None:
379
+ cur = conn.execute(
380
+ """
381
+ INSERT INTO files (
382
+ instrument_id, path, format, start_time, end_time,
383
+ expected_rows, actual_rows, missing_rows, duplicate_rows,
384
+ invalid_rows, gaps, size_bytes, checksum_sha256,
385
+ schema_version, status, created_at, updated_at, validated_at
386
+ ) VALUES (?, ?, 'parquet', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
387
+ """,
388
+ (
389
+ instrument_id,
390
+ path,
391
+ start_time,
392
+ end_time,
393
+ quality.expected_rows,
394
+ quality.actual_rows,
395
+ quality.missing_rows,
396
+ quality.duplicate_rows,
397
+ quality.invalid_rows,
398
+ gaps_raw,
399
+ size_bytes,
400
+ checksum,
401
+ SCHEMA_VERSION,
402
+ quality.status,
403
+ now,
404
+ now,
405
+ now,
406
+ ),
407
+ )
408
+ file_id = cur.lastrowid
409
+ else:
410
+ conn.execute(
411
+ """
412
+ UPDATE files SET
413
+ start_time = ?, end_time = ?,
414
+ expected_rows = ?, actual_rows = ?,
415
+ missing_rows = ?, duplicate_rows = ?, invalid_rows = ?,
416
+ gaps = ?, size_bytes = ?, checksum_sha256 = ?,
417
+ schema_version = ?, status = ?,
418
+ updated_at = ?, validated_at = ?
419
+ WHERE path = ?
420
+ """,
421
+ (
422
+ start_time,
423
+ end_time,
424
+ quality.expected_rows,
425
+ quality.actual_rows,
426
+ quality.missing_rows,
427
+ quality.duplicate_rows,
428
+ quality.invalid_rows,
429
+ gaps_raw,
430
+ size_bytes,
431
+ checksum,
432
+ SCHEMA_VERSION,
433
+ quality.status,
434
+ now,
435
+ now,
436
+ path,
437
+ ),
438
+ )
439
+ file_id = existing.id
440
+
441
+ conn.execute(
442
+ """
443
+ INSERT INTO validation_runs (
444
+ file_id, validator_version, expected_rows, actual_rows,
445
+ missing_rows, duplicate_rows, invalid_rows, status,
446
+ started_at, completed_at
447
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
448
+ """,
449
+ (
450
+ file_id,
451
+ VALIDATOR_VERSION,
452
+ quality.expected_rows,
453
+ quality.actual_rows,
454
+ quality.missing_rows,
455
+ quality.duplicate_rows,
456
+ quality.invalid_rows,
457
+ quality.status,
458
+ now,
459
+ now,
460
+ ),
461
+ )
462
+ conn.commit()
463
+
464
+ def _insert_ingestion(self, **kwargs: Any) -> int:
465
+ cur = self._store.conn.execute(
466
+ """
467
+ INSERT INTO ingestions (
468
+ source, source_instrument_id, requested_start, requested_end,
469
+ received_rows, accepted_rows, rejected_rows, collector_version,
470
+ started_at, completed_at, status
471
+ ) VALUES (?, ?, ?, ?, 0, 0, 0, ?, ?, NULL, ?)
472
+ """,
473
+ (
474
+ kwargs["source"],
475
+ kwargs.get("source_instrument_id"),
476
+ kwargs.get("requested_start"),
477
+ kwargs.get("requested_end"),
478
+ kwargs.get("collector_version"),
479
+ kwargs["started_at"],
480
+ kwargs["status"],
481
+ ),
482
+ )
483
+ self._store.conn.commit()
484
+ return int(cur.lastrowid)
485
+
486
+ def _finish_ingestion(
487
+ self,
488
+ ingestion_id: int,
489
+ *,
490
+ received: int,
491
+ accepted: int,
492
+ rejected: int,
493
+ status: str,
494
+ ) -> None:
495
+ self._store.conn.execute(
496
+ """
497
+ UPDATE ingestions SET
498
+ received_rows = ?, accepted_rows = ?, rejected_rows = ?,
499
+ completed_at = ?, status = ?
500
+ WHERE id = ?
501
+ """,
502
+ (received, accepted, rejected, _now(), status, ingestion_id),
503
+ )
504
+ self._store.conn.commit()
505
+
506
+
507
+ def _parse_bound(value: str | datetime, *, end: bool) -> datetime:
508
+ if isinstance(value, datetime):
509
+ ts = value
510
+ else:
511
+ text = value
512
+ if len(text) == 10:
513
+ # date only
514
+ if end:
515
+ text = text + "T23:59:00"
516
+ else:
517
+ text = text + "T00:00:00"
518
+ ts = datetime.fromisoformat(text)
519
+ if ts.tzinfo is None:
520
+ ts = ts.replace(tzinfo=IST)
521
+ else:
522
+ ts = ts.astimezone(IST)
523
+ return ts
tt_data_store/db.py ADDED
@@ -0,0 +1,40 @@
1
+ """SQLite metadata database for the archive catalogue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from importlib import resources
7
+ from pathlib import Path
8
+
9
+
10
+ def connect(db_path: Path) -> sqlite3.Connection:
11
+ conn = sqlite3.connect(db_path)
12
+ conn.row_factory = sqlite3.Row
13
+ conn.execute("PRAGMA foreign_keys = ON")
14
+ return conn
15
+
16
+
17
+ def init_schema(conn: sqlite3.Connection) -> None:
18
+ sql = resources.files("tt_data_store").joinpath("schema.sql").read_text(encoding="utf-8")
19
+ conn.executescript(sql)
20
+ conn.commit()
21
+
22
+
23
+ def migrate_schema(conn: sqlite3.Connection) -> None:
24
+ """Apply additive migrations for existing archives."""
25
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(files)")}
26
+ if "last_synced_checksum_sha256" not in cols:
27
+ conn.execute(
28
+ "ALTER TABLE files ADD COLUMN last_synced_checksum_sha256 TEXT"
29
+ )
30
+ conn.commit()
31
+
32
+
33
+ def open_or_create(db_path: Path) -> sqlite3.Connection:
34
+ exists = db_path.exists()
35
+ conn = connect(db_path)
36
+ if not exists:
37
+ init_schema(conn)
38
+ else:
39
+ migrate_schema(conn)
40
+ return conn