dirsql 0.3.55__tar.gz → 0.3.56__tar.gz
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.
- {dirsql-0.3.55 → dirsql-0.3.56}/Cargo.lock +1 -1
- {dirsql-0.3.55 → dirsql-0.3.56}/PKG-INFO +1 -1
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/Cargo.toml +1 -1
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/e2e-attestation.json +2 -2
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/db.rs +383 -2
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/persist.rs +34 -1
- {dirsql-0.3.55 → dirsql-0.3.56}/Cargo.toml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/README.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/_async.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/_dirsql.pyi +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/binary_path.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/interpret/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/is_windows.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/main.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/resolve_config_extensions.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/py.typed +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/resolve_config_extensions.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/resolve_extension.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/AGENTS.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/api/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/config.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/http-api.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/init.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/server.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/getting-started.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/async.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/crdt.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/persistence.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/querying.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/tables.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/watching.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/migrations.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/package.json +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/playwright.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/integration/sidebar.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/docs/vitest.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/README.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/conftest.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/AGENTS.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/api/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/config.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/http-api.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/init.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/server.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/getting-started.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/async.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/crdt.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/persistence.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/querying.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/tables.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/watching.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/migrations.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/package.json +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/playwright.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/vitest.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/src/lib.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/binding/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/conftest.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/e2e/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/integration/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/smoke/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/Cargo.toml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/README.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/db_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/differ_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/matcher_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/scanner_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/api/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/config.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/http-api.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/init.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/server.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/getting-started.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/async.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/crdt.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/persistence.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/querying.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/tables.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/watching.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/migrations.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/bin/dirsql.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/init.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/mod.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/router.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/serialize.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/server.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/command.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/config.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/differ.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/lib.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/matcher.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/scanner.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/watcher.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.56}/pyproject.toml +0 -0
|
@@ -4,7 +4,7 @@ name = "dirsql-py-ext"
|
|
|
4
4
|
# pypi/maturin handler can rewrite it via `write-version` before
|
|
5
5
|
# `maturin build`. `pyproject.toml` declares `dynamic = ["version"]`
|
|
6
6
|
# and maturin reads this field. Mirrors `packages/rust/Cargo.toml`.
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.56"
|
|
8
8
|
edition.workspace = true
|
|
9
9
|
publish = false
|
|
10
10
|
readme = "README.md"
|
|
@@ -51,6 +51,37 @@ pub fn validate_identifier(s: &str) -> Result<()> {
|
|
|
51
51
|
|
|
52
52
|
pub type Result<T> = std::result::Result<T, DbError>;
|
|
53
53
|
|
|
54
|
+
/// Name of the internal row-bookkeeping table (issue #359, epic #358).
|
|
55
|
+
///
|
|
56
|
+
/// Maps every inserted user row back to the file that produced it —
|
|
57
|
+
/// `(table_name, file_path, row_index, rowid_ref)` — mirroring the injected
|
|
58
|
+
/// `_dirsql_file_path` / `_dirsql_row_index` tracking columns. Stage 1 keeps
|
|
59
|
+
/// the injected columns authoritative and *dual-writes* this table in the same
|
|
60
|
+
/// SQLite transaction as each row write, so a later release can drop the
|
|
61
|
+
/// columns and read ownership from here instead.
|
|
62
|
+
pub const INTERNAL_ROWS_TABLE: &str = "_dirsql_internal_rows";
|
|
63
|
+
|
|
64
|
+
/// Create the internal `_dirsql_internal_rows` bookkeeping table and its
|
|
65
|
+
/// by-file index if they don't already exist. Idempotent, so it is safe to
|
|
66
|
+
/// call on every `Db` construction and on every persistent-cache open.
|
|
67
|
+
///
|
|
68
|
+
/// A **real** table (not virtual): it lives in the persisted cache and is
|
|
69
|
+
/// written inside the same transaction as the row inserts/deletes it
|
|
70
|
+
/// describes, which is what gives the mapping crash-atomicity with the user
|
|
71
|
+
/// rows.
|
|
72
|
+
pub fn ensure_internal_rows_table(conn: &Connection) -> rusqlite::Result<()> {
|
|
73
|
+
conn.execute_batch(
|
|
74
|
+
"CREATE TABLE IF NOT EXISTS _dirsql_internal_rows (
|
|
75
|
+
table_name TEXT NOT NULL,
|
|
76
|
+
file_path TEXT NOT NULL,
|
|
77
|
+
row_index INTEGER NOT NULL,
|
|
78
|
+
rowid_ref INTEGER NOT NULL
|
|
79
|
+
);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS _dirsql_internal_rows_by_file
|
|
81
|
+
ON _dirsql_internal_rows(table_name, file_path);",
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
pub struct Db {
|
|
55
86
|
conn: Connection,
|
|
56
87
|
}
|
|
@@ -58,6 +89,7 @@ pub struct Db {
|
|
|
58
89
|
impl Db {
|
|
59
90
|
pub fn new() -> Result<Self> {
|
|
60
91
|
let conn = Connection::open_in_memory()?;
|
|
92
|
+
ensure_internal_rows_table(&conn)?;
|
|
61
93
|
Ok(Self { conn })
|
|
62
94
|
}
|
|
63
95
|
|
|
@@ -65,6 +97,7 @@ impl Db {
|
|
|
65
97
|
/// cache path; in-memory mode is the default.
|
|
66
98
|
pub fn open(path: &Path) -> Result<Self> {
|
|
67
99
|
let conn = Connection::open(path)?;
|
|
100
|
+
ensure_internal_rows_table(&conn)?;
|
|
68
101
|
Ok(Self { conn })
|
|
69
102
|
}
|
|
70
103
|
|
|
@@ -120,6 +153,18 @@ impl Db {
|
|
|
120
153
|
}
|
|
121
154
|
let table = parse_table_name(ddl).ok_or_else(|| DbError::DdlParse(ddl.to_string()))?;
|
|
122
155
|
validate_identifier(&table)?;
|
|
156
|
+
// A `WITHOUT ROWID` table has no rowid, so `last_insert_rowid()` cannot
|
|
157
|
+
// identify the inserted row and the `_dirsql_internal_rows.rowid_ref`
|
|
158
|
+
// mapping (epic #358) would be meaningless. Stage 1 only warns; the hard
|
|
159
|
+
// rejection lands in stage 3. The injected columns stay authoritative
|
|
160
|
+
// here, so the table still works today.
|
|
161
|
+
if is_without_rowid_ddl(ddl) {
|
|
162
|
+
eprintln!(
|
|
163
|
+
"dirsql: table `{table}` is declared WITHOUT ROWID; internal row \
|
|
164
|
+
bookkeeping relies on rowid and WITHOUT ROWID tables will be \
|
|
165
|
+
rejected in a future release"
|
|
166
|
+
);
|
|
167
|
+
}
|
|
123
168
|
let augmented = inject_tracking_columns(ddl)?;
|
|
124
169
|
self.conn.execute(&augmented, [])?;
|
|
125
170
|
Ok(())
|
|
@@ -232,18 +277,112 @@ impl Db {
|
|
|
232
277
|
|
|
233
278
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
|
|
234
279
|
params.iter().map(|p| p.as_ref()).collect();
|
|
235
|
-
|
|
280
|
+
|
|
281
|
+
// Dual-write (epic #358): the user-row insert and its
|
|
282
|
+
// `_dirsql_internal_rows` mapping row commit in ONE transaction, so a
|
|
283
|
+
// crash between them can never leave a row without its mapping (or vice
|
|
284
|
+
// versa). `last_insert_rowid()` is read *after* the user insert and
|
|
285
|
+
// *before* the mapping insert, so it captures the user row's rowid
|
|
286
|
+
// (including a user-declared `INTEGER PRIMARY KEY` rowid alias).
|
|
287
|
+
let tx = self.conn.unchecked_transaction()?;
|
|
288
|
+
tx.execute(&sql, param_refs.as_slice())?;
|
|
289
|
+
let rowid = tx.last_insert_rowid();
|
|
290
|
+
tx.execute(
|
|
291
|
+
"INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
|
|
292
|
+
VALUES (?1, ?2, ?3, ?4)",
|
|
293
|
+
rusqlite::params![table, file_path, row_index as i64, rowid],
|
|
294
|
+
)?;
|
|
295
|
+
tx.commit()?;
|
|
236
296
|
Ok(())
|
|
237
297
|
}
|
|
238
298
|
|
|
239
299
|
/// Delete all rows that were produced by a given file path.
|
|
300
|
+
///
|
|
301
|
+
/// The user-row deletes and the matching `_dirsql_internal_rows` mapping
|
|
302
|
+
/// deletes commit in ONE transaction (epic #358), so the mapping never
|
|
303
|
+
/// outlives the rows it describes.
|
|
240
304
|
pub fn delete_rows_by_file(&self, table: &str, file_path: &str) -> Result<usize> {
|
|
241
305
|
validate_identifier(table)?;
|
|
306
|
+
let tx = self.conn.unchecked_transaction()?;
|
|
242
307
|
let sql = format!("DELETE FROM {} WHERE _dirsql_file_path = ?1", table);
|
|
243
|
-
let count =
|
|
308
|
+
let count = tx.execute(&sql, [file_path])?;
|
|
309
|
+
tx.execute(
|
|
310
|
+
"DELETE FROM _dirsql_internal_rows WHERE table_name = ?1 AND file_path = ?2",
|
|
311
|
+
rusqlite::params![table, file_path],
|
|
312
|
+
)?;
|
|
313
|
+
tx.commit()?;
|
|
244
314
|
Ok(count)
|
|
245
315
|
}
|
|
246
316
|
|
|
317
|
+
/// Debug/test equivalence guard for the dual-write mirror (epic #358).
|
|
318
|
+
///
|
|
319
|
+
/// Asserts that the `_dirsql_internal_rows` mapping for `table` exactly
|
|
320
|
+
/// matches the column-derived tracking state of the live user rows: every
|
|
321
|
+
/// user row's `(rowid, _dirsql_file_path, _dirsql_row_index)` triple must
|
|
322
|
+
/// have one corresponding mapping row `(rowid_ref, file_path, row_index)`,
|
|
323
|
+
/// and vice versa. Returns [`DbError::SchemaMismatch`] describing the drift
|
|
324
|
+
/// otherwise. While the injected columns remain authoritative (stages 1–2),
|
|
325
|
+
/// this is the guard that the new bookkeeping never diverges.
|
|
326
|
+
///
|
|
327
|
+
/// Not valid for `WITHOUT ROWID` tables (they have no `rowid` column).
|
|
328
|
+
pub fn check_row_mapping_equivalence(&self, table: &str) -> Result<()> {
|
|
329
|
+
validate_identifier(table)?;
|
|
330
|
+
let mut column_state = self.column_tracking_triples(table)?;
|
|
331
|
+
let mut mapping_state = self.mapping_triples(table)?;
|
|
332
|
+
column_state.sort();
|
|
333
|
+
mapping_state.sort();
|
|
334
|
+
if column_state != mapping_state {
|
|
335
|
+
return Err(DbError::SchemaMismatch(format!(
|
|
336
|
+
"row mapping drift for table {table}: \
|
|
337
|
+
column-derived {column_state:?} != mapping-derived {mapping_state:?}"
|
|
338
|
+
)));
|
|
339
|
+
}
|
|
340
|
+
Ok(())
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// Column-derived tracking triples for `table`: `(rowid,
|
|
344
|
+
/// _dirsql_file_path, _dirsql_row_index)` for every live user row.
|
|
345
|
+
fn column_tracking_triples(&self, table: &str) -> Result<Vec<(i64, String, i64)>> {
|
|
346
|
+
let sql = format!(
|
|
347
|
+
"SELECT rowid, _dirsql_file_path, _dirsql_row_index FROM {}",
|
|
348
|
+
table
|
|
349
|
+
);
|
|
350
|
+
let mut stmt = self.conn.prepare(&sql)?;
|
|
351
|
+
let rows = stmt.query_map([], |row| {
|
|
352
|
+
Ok((
|
|
353
|
+
row.get::<_, i64>(0)?,
|
|
354
|
+
row.get::<_, String>(1)?,
|
|
355
|
+
row.get::<_, i64>(2)?,
|
|
356
|
+
))
|
|
357
|
+
})?;
|
|
358
|
+
let mut out = Vec::new();
|
|
359
|
+
for row in rows {
|
|
360
|
+
out.push(row?);
|
|
361
|
+
}
|
|
362
|
+
Ok(out)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/// Mapping-derived tracking triples for `table`: `(rowid_ref, file_path,
|
|
366
|
+
/// row_index)` from `_dirsql_internal_rows`.
|
|
367
|
+
fn mapping_triples(&self, table: &str) -> Result<Vec<(i64, String, i64)>> {
|
|
368
|
+
let mut stmt = self.conn.prepare(
|
|
369
|
+
"SELECT rowid_ref, file_path, row_index FROM _dirsql_internal_rows \
|
|
370
|
+
WHERE table_name = ?1",
|
|
371
|
+
)?;
|
|
372
|
+
let rows = stmt.query_map([table], |row| {
|
|
373
|
+
Ok((
|
|
374
|
+
row.get::<_, i64>(0)?,
|
|
375
|
+
row.get::<_, String>(1)?,
|
|
376
|
+
row.get::<_, i64>(2)?,
|
|
377
|
+
))
|
|
378
|
+
})?;
|
|
379
|
+
let mut out = Vec::new();
|
|
380
|
+
for row in rows {
|
|
381
|
+
out.push(row?);
|
|
382
|
+
}
|
|
383
|
+
Ok(out)
|
|
384
|
+
}
|
|
385
|
+
|
|
247
386
|
/// Query the database, returning rows as a list of column-name -> value maps.
|
|
248
387
|
///
|
|
249
388
|
/// Rejects any statement that SQLite itself classifies as a write
|
|
@@ -497,6 +636,16 @@ fn is_virtual_table_ddl(ddl: &str) -> bool {
|
|
|
497
636
|
normalized.to_uppercase().contains("CREATE VIRTUAL TABLE")
|
|
498
637
|
}
|
|
499
638
|
|
|
639
|
+
/// True if `ddl` declares a `WITHOUT ROWID` table. Such tables have no rowid,
|
|
640
|
+
/// so `last_insert_rowid()` cannot identify an inserted row and the
|
|
641
|
+
/// `_dirsql_internal_rows.rowid_ref` mapping (epic #358) is meaningless. Stage
|
|
642
|
+
/// 1 warns; stage 3 rejects. Whitespace is normalized so `WITHOUT ROWID`
|
|
643
|
+
/// and newline-separated forms are still detected.
|
|
644
|
+
fn is_without_rowid_ddl(ddl: &str) -> bool {
|
|
645
|
+
let normalized = ddl.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
646
|
+
normalized.to_uppercase().contains("WITHOUT ROWID")
|
|
647
|
+
}
|
|
648
|
+
|
|
500
649
|
impl From<rusqlite::types::Value> for Value {
|
|
501
650
|
fn from(v: rusqlite::types::Value) -> Self {
|
|
502
651
|
match v {
|
|
@@ -1323,4 +1472,236 @@ mod tests {
|
|
|
1323
1472
|
"CREATE TABLE IF NOT EXISTS x (a TEXT)"
|
|
1324
1473
|
));
|
|
1325
1474
|
}
|
|
1475
|
+
|
|
1476
|
+
// --- _dirsql_internal_rows mapping (epic #358, stage 1) ---
|
|
1477
|
+
|
|
1478
|
+
/// Read the raw mapping rows for a table, ordered for stable assertions.
|
|
1479
|
+
fn mapping_rows(db: &Db, table: &str) -> Vec<(String, i64, i64)> {
|
|
1480
|
+
let mut stmt = db
|
|
1481
|
+
.conn
|
|
1482
|
+
.prepare(
|
|
1483
|
+
"SELECT file_path, row_index, rowid_ref FROM _dirsql_internal_rows \
|
|
1484
|
+
WHERE table_name = ?1 ORDER BY rowid_ref",
|
|
1485
|
+
)
|
|
1486
|
+
.unwrap();
|
|
1487
|
+
stmt.query_map([table], |r| {
|
|
1488
|
+
Ok((
|
|
1489
|
+
r.get::<_, String>(0)?,
|
|
1490
|
+
r.get::<_, i64>(1)?,
|
|
1491
|
+
r.get::<_, i64>(2)?,
|
|
1492
|
+
))
|
|
1493
|
+
})
|
|
1494
|
+
.unwrap()
|
|
1495
|
+
.map(|r| r.unwrap())
|
|
1496
|
+
.collect()
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
#[test]
|
|
1500
|
+
fn ensure_internal_rows_table_is_idempotent() {
|
|
1501
|
+
let db = Db::new().unwrap();
|
|
1502
|
+
// Db::new already created it; calling again must not error.
|
|
1503
|
+
ensure_internal_rows_table(&db.conn).unwrap();
|
|
1504
|
+
ensure_internal_rows_table(&db.conn).unwrap();
|
|
1505
|
+
// The table and its by-file index both exist.
|
|
1506
|
+
let table_count: i64 = db
|
|
1507
|
+
.conn
|
|
1508
|
+
.query_row(
|
|
1509
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
|
1510
|
+
[INTERNAL_ROWS_TABLE],
|
|
1511
|
+
|r| r.get(0),
|
|
1512
|
+
)
|
|
1513
|
+
.unwrap();
|
|
1514
|
+
assert_eq!(table_count, 1);
|
|
1515
|
+
let index_count: i64 = db
|
|
1516
|
+
.conn
|
|
1517
|
+
.query_row(
|
|
1518
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='_dirsql_internal_rows_by_file'",
|
|
1519
|
+
[],
|
|
1520
|
+
|r| r.get(0),
|
|
1521
|
+
)
|
|
1522
|
+
.unwrap();
|
|
1523
|
+
assert_eq!(index_count, 1);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
#[test]
|
|
1527
|
+
fn insert_row_records_mapping() {
|
|
1528
|
+
let db = Db::new().unwrap();
|
|
1529
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1530
|
+
db.insert_row(
|
|
1531
|
+
"t",
|
|
1532
|
+
&HashMap::from([("id".into(), Value::Text("a".into()))]),
|
|
1533
|
+
"f.jsonl",
|
|
1534
|
+
3,
|
|
1535
|
+
)
|
|
1536
|
+
.unwrap();
|
|
1537
|
+
|
|
1538
|
+
let rows = mapping_rows(&db, "t");
|
|
1539
|
+
assert_eq!(rows.len(), 1);
|
|
1540
|
+
assert_eq!(rows[0].0, "f.jsonl");
|
|
1541
|
+
assert_eq!(rows[0].1, 3);
|
|
1542
|
+
// The captured rowid_ref points at the user row.
|
|
1543
|
+
let user_rowid: i64 = db
|
|
1544
|
+
.conn
|
|
1545
|
+
.query_row("SELECT rowid FROM t", [], |r| r.get(0))
|
|
1546
|
+
.unwrap();
|
|
1547
|
+
assert_eq!(rows[0].2, user_rowid);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
#[test]
|
|
1551
|
+
fn insert_row_captures_user_declared_rowid_alias() {
|
|
1552
|
+
// A user-declared `INTEGER PRIMARY KEY` is a rowid alias: the inserted
|
|
1553
|
+
// value becomes the rowid, and `last_insert_rowid()` must capture it.
|
|
1554
|
+
let db = Db::new().unwrap();
|
|
1555
|
+
db.create_table("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
|
1556
|
+
.unwrap();
|
|
1557
|
+
db.insert_row(
|
|
1558
|
+
"t",
|
|
1559
|
+
&HashMap::from([
|
|
1560
|
+
("id".into(), Value::Integer(42)),
|
|
1561
|
+
("name".into(), Value::Text("x".into())),
|
|
1562
|
+
]),
|
|
1563
|
+
"f.json",
|
|
1564
|
+
0,
|
|
1565
|
+
)
|
|
1566
|
+
.unwrap();
|
|
1567
|
+
|
|
1568
|
+
let rows = mapping_rows(&db, "t");
|
|
1569
|
+
assert_eq!(rows.len(), 1);
|
|
1570
|
+
assert_eq!(rows[0].2, 42, "rowid_ref must be the user-declared rowid");
|
|
1571
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
#[test]
|
|
1575
|
+
fn delete_rows_by_file_removes_mapping() {
|
|
1576
|
+
let db = Db::new().unwrap();
|
|
1577
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1578
|
+
for (i, file) in ["a.jsonl", "a.jsonl", "b.jsonl"].iter().enumerate() {
|
|
1579
|
+
db.insert_row(
|
|
1580
|
+
"t",
|
|
1581
|
+
&HashMap::from([("id".into(), Value::Text(i.to_string()))]),
|
|
1582
|
+
file,
|
|
1583
|
+
i,
|
|
1584
|
+
)
|
|
1585
|
+
.unwrap();
|
|
1586
|
+
}
|
|
1587
|
+
db.delete_rows_by_file("t", "a.jsonl").unwrap();
|
|
1588
|
+
|
|
1589
|
+
// Only b.jsonl's mapping survives, in lockstep with the user rows.
|
|
1590
|
+
let rows = mapping_rows(&db, "t");
|
|
1591
|
+
assert_eq!(rows.len(), 1);
|
|
1592
|
+
assert_eq!(rows[0].0, "b.jsonl");
|
|
1593
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
#[test]
|
|
1597
|
+
fn failed_row_insert_leaves_no_mapping_row() {
|
|
1598
|
+
// Transactional coupling, forward direction: when the user-row insert
|
|
1599
|
+
// fails, its mapping row must not be written either.
|
|
1600
|
+
let db = Db::new().unwrap();
|
|
1601
|
+
db.create_table("CREATE TABLE t (id TEXT UNIQUE)").unwrap();
|
|
1602
|
+
db.insert_row(
|
|
1603
|
+
"t",
|
|
1604
|
+
&HashMap::from([("id".into(), Value::Text("dup".into()))]),
|
|
1605
|
+
"a.json",
|
|
1606
|
+
0,
|
|
1607
|
+
)
|
|
1608
|
+
.unwrap();
|
|
1609
|
+
// Second insert of the same UNIQUE value fails.
|
|
1610
|
+
let err = db
|
|
1611
|
+
.insert_row(
|
|
1612
|
+
"t",
|
|
1613
|
+
&HashMap::from([("id".into(), Value::Text("dup".into()))]),
|
|
1614
|
+
"b.json",
|
|
1615
|
+
0,
|
|
1616
|
+
)
|
|
1617
|
+
.unwrap_err();
|
|
1618
|
+
assert!(matches!(err, DbError::Sqlite(_)), "got: {err}");
|
|
1619
|
+
|
|
1620
|
+
let rows = mapping_rows(&db, "t");
|
|
1621
|
+
assert_eq!(
|
|
1622
|
+
rows.len(),
|
|
1623
|
+
1,
|
|
1624
|
+
"the failed insert must not add a mapping row"
|
|
1625
|
+
);
|
|
1626
|
+
assert_eq!(rows[0].0, "a.json");
|
|
1627
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
#[test]
|
|
1631
|
+
fn failed_mapping_insert_rolls_back_row_insert() {
|
|
1632
|
+
// Transactional coupling, reverse direction: if the mapping insert
|
|
1633
|
+
// fails, the user-row insert must roll back too (nothing left behind).
|
|
1634
|
+
let db = Db::new().unwrap();
|
|
1635
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1636
|
+
// Remove the mapping table so the second statement in the transaction
|
|
1637
|
+
// errors with "no such table".
|
|
1638
|
+
db.conn
|
|
1639
|
+
.execute("DROP TABLE _dirsql_internal_rows", [])
|
|
1640
|
+
.unwrap();
|
|
1641
|
+
let err = db
|
|
1642
|
+
.insert_row(
|
|
1643
|
+
"t",
|
|
1644
|
+
&HashMap::from([("id".into(), Value::Text("x".into()))]),
|
|
1645
|
+
"a.json",
|
|
1646
|
+
0,
|
|
1647
|
+
)
|
|
1648
|
+
.unwrap_err();
|
|
1649
|
+
assert!(matches!(err, DbError::Sqlite(_)), "got: {err}");
|
|
1650
|
+
|
|
1651
|
+
let count: i64 = db
|
|
1652
|
+
.conn
|
|
1653
|
+
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
|
1654
|
+
.unwrap();
|
|
1655
|
+
assert_eq!(count, 0, "the user row must have rolled back");
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
#[test]
|
|
1659
|
+
fn check_row_mapping_equivalence_detects_drift() {
|
|
1660
|
+
let db = Db::new().unwrap();
|
|
1661
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1662
|
+
db.insert_row(
|
|
1663
|
+
"t",
|
|
1664
|
+
&HashMap::from([("id".into(), Value::Text("a".into()))]),
|
|
1665
|
+
"f.json",
|
|
1666
|
+
0,
|
|
1667
|
+
)
|
|
1668
|
+
.unwrap();
|
|
1669
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1670
|
+
|
|
1671
|
+
// Corrupt the mapping directly, bypassing the dual-write path.
|
|
1672
|
+
db.conn
|
|
1673
|
+
.execute("DELETE FROM _dirsql_internal_rows", [])
|
|
1674
|
+
.unwrap();
|
|
1675
|
+
let err = db.check_row_mapping_equivalence("t").unwrap_err();
|
|
1676
|
+
assert!(matches!(err, DbError::SchemaMismatch(_)), "got: {err}");
|
|
1677
|
+
assert!(err.to_string().contains("row mapping drift"));
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
#[test]
|
|
1681
|
+
fn check_row_mapping_equivalence_rejects_bad_table_name() {
|
|
1682
|
+
let db = Db::new().unwrap();
|
|
1683
|
+
let err = db.check_row_mapping_equivalence("bad name").unwrap_err();
|
|
1684
|
+
assert!(matches!(err, DbError::InvalidIdentifier(_)), "got: {err}");
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
#[test]
|
|
1688
|
+
fn create_table_allows_without_rowid_and_warns() {
|
|
1689
|
+
// Stage 1 only warns (via eprintln); the table is still created.
|
|
1690
|
+
let db = Db::new().unwrap();
|
|
1691
|
+
db.create_table("CREATE TABLE t (id TEXT PRIMARY KEY) WITHOUT ROWID")
|
|
1692
|
+
.unwrap();
|
|
1693
|
+
let rows = db.query("SELECT * FROM t").unwrap();
|
|
1694
|
+
assert_eq!(rows.len(), 0);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
#[test]
|
|
1698
|
+
fn is_without_rowid_ddl_detects_variants() {
|
|
1699
|
+
assert!(is_without_rowid_ddl(
|
|
1700
|
+
"CREATE TABLE t (id TEXT PRIMARY KEY) WITHOUT ROWID"
|
|
1701
|
+
));
|
|
1702
|
+
assert!(is_without_rowid_ddl(
|
|
1703
|
+
"create table t (id text primary key)\n without rowid"
|
|
1704
|
+
));
|
|
1705
|
+
assert!(!is_without_rowid_ddl("CREATE TABLE t (id TEXT)"));
|
|
1706
|
+
}
|
|
1326
1707
|
}
|
|
@@ -19,7 +19,12 @@ use crate::Table;
|
|
|
19
19
|
|
|
20
20
|
/// Sidecar schema version. Bumped on any breaking change to the layout of
|
|
21
21
|
/// `_dirsql_files` / `_dirsql_meta`.
|
|
22
|
-
|
|
22
|
+
///
|
|
23
|
+
/// `2` (epic #358, stage 1): adds the `_dirsql_internal_rows` bookkeeping
|
|
24
|
+
/// table. A cache written by an older build lacks the mapping, so the version
|
|
25
|
+
/// mismatch forces a penalty-free full rebuild on first startup after upgrade,
|
|
26
|
+
/// which populates the mapping from scratch.
|
|
27
|
+
pub const SCHEMA_VERSION: &str = "2";
|
|
23
28
|
|
|
24
29
|
/// Bumped whenever any built-in parser changes its row-shape contract. A
|
|
25
30
|
/// mismatch forces a full rebuild.
|
|
@@ -372,6 +377,10 @@ pub fn drop_user_tables(conn: &Connection) -> rusqlite::Result<()> {
|
|
|
372
377
|
conn.execute(&format!("DROP TABLE IF EXISTS \"{name}\""), [])?;
|
|
373
378
|
}
|
|
374
379
|
conn.execute("DELETE FROM _dirsql_files", [])?;
|
|
380
|
+
// Wipe the row mapping too (epic #358): a cold rebuild re-ingests every
|
|
381
|
+
// file, and `insert_row` repopulates the mapping, so any stale rows here
|
|
382
|
+
// must go or they would duplicate / orphan the fresh state.
|
|
383
|
+
conn.execute("DELETE FROM _dirsql_internal_rows", [])?;
|
|
375
384
|
Ok(())
|
|
376
385
|
}
|
|
377
386
|
|
|
@@ -530,9 +539,26 @@ mod tests {
|
|
|
530
539
|
fn drop_user_tables_clears_user_data_and_files_index() {
|
|
531
540
|
let conn = Connection::open_in_memory().unwrap();
|
|
532
541
|
create_sidecar_tables(&conn).unwrap();
|
|
542
|
+
// The internal row mapping (epic #358) is created by `Db::open` in
|
|
543
|
+
// production; declare it inline here since this test uses a raw
|
|
544
|
+
// connection (calling the `db` helper would break unit isolation).
|
|
545
|
+
conn.execute(
|
|
546
|
+
"CREATE TABLE _dirsql_internal_rows (
|
|
547
|
+
table_name TEXT NOT NULL, file_path TEXT NOT NULL,
|
|
548
|
+
row_index INTEGER NOT NULL, rowid_ref INTEGER NOT NULL)",
|
|
549
|
+
[],
|
|
550
|
+
)
|
|
551
|
+
.unwrap();
|
|
533
552
|
conn.execute("CREATE TABLE rows (x TEXT)", []).unwrap();
|
|
534
553
|
conn.execute("INSERT INTO rows (x) VALUES ('a')", [])
|
|
535
554
|
.unwrap();
|
|
555
|
+
// A stale mapping row that a cold rebuild must wipe.
|
|
556
|
+
conn.execute(
|
|
557
|
+
"INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
|
|
558
|
+
VALUES ('rows', 'a.csv', 0, 1)",
|
|
559
|
+
[],
|
|
560
|
+
)
|
|
561
|
+
.unwrap();
|
|
536
562
|
let stat = FileStat {
|
|
537
563
|
size: 1,
|
|
538
564
|
mtime_ns: 1,
|
|
@@ -554,6 +580,13 @@ mod tests {
|
|
|
554
580
|
.unwrap();
|
|
555
581
|
assert_eq!(table_count, 0);
|
|
556
582
|
assert!(read_cached_files(&conn).unwrap().is_empty());
|
|
583
|
+
// The internal row mapping is wiped too, ready for a fresh re-ingest.
|
|
584
|
+
let mapping_count: i64 = conn
|
|
585
|
+
.query_row("SELECT COUNT(*) FROM _dirsql_internal_rows", [], |r| {
|
|
586
|
+
r.get(0)
|
|
587
|
+
})
|
|
588
|
+
.unwrap();
|
|
589
|
+
assert_eq!(mapping_count, 0);
|
|
557
590
|
// Meta is preserved by drop_user_tables -- we replace it explicitly.
|
|
558
591
|
let m = read_meta(&conn).unwrap();
|
|
559
592
|
assert_eq!(m.get("x"), Some(&"y".to_string()));
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/integration/language-flag.spec.ts
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|