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.
Files changed (124) hide show
  1. {dirsql-0.3.55 → dirsql-0.3.56}/Cargo.lock +1 -1
  2. {dirsql-0.3.55 → dirsql-0.3.56}/PKG-INFO +1 -1
  3. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/db.rs +383 -2
  6. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/persist.rs +34 -1
  7. {dirsql-0.3.55 → dirsql-0.3.56}/Cargo.toml +0 -0
  8. {dirsql-0.3.55 → dirsql-0.3.56}/README.md +0 -0
  9. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/__init__.py +0 -0
  10. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/_async.py +0 -0
  11. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/_dirsql.pyi +0 -0
  12. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/__init__.py +0 -0
  13. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/binary_path.py +0 -0
  14. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/interpret/__init__.py +0 -0
  15. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/is_windows.py +0 -0
  16. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/main.py +0 -0
  17. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/cli/resolve_config_extensions.py +0 -0
  18. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/py.typed +0 -0
  19. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/resolve_config_extensions.py +0 -0
  20. {dirsql-0.3.55 → dirsql-0.3.56}/dirsql/resolve_extension.py +0 -0
  21. {dirsql-0.3.55 → dirsql-0.3.56}/docs/.claude/CLAUDE.md +0 -0
  22. {dirsql-0.3.55 → dirsql-0.3.56}/docs/.vitepress/config.ts +0 -0
  23. {dirsql-0.3.55 → dirsql-0.3.56}/docs/.vitepress/theme/index.ts +0 -0
  24. {dirsql-0.3.55 → dirsql-0.3.56}/docs/.vitepress/theme/lang.ts +0 -0
  25. {dirsql-0.3.55 → dirsql-0.3.56}/docs/AGENTS.md +0 -0
  26. {dirsql-0.3.55 → dirsql-0.3.56}/docs/api/index.md +0 -0
  27. {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/config.md +0 -0
  28. {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/http-api.md +0 -0
  29. {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/index.md +0 -0
  30. {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/init.md +0 -0
  31. {dirsql-0.3.55 → dirsql-0.3.56}/docs/cli/server.md +0 -0
  32. {dirsql-0.3.55 → dirsql-0.3.56}/docs/getting-started.md +0 -0
  33. {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/async.md +0 -0
  34. {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/crdt.md +0 -0
  35. {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/persistence.md +0 -0
  36. {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/querying.md +0 -0
  37. {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/tables.md +0 -0
  38. {dirsql-0.3.55 → dirsql-0.3.56}/docs/guide/watching.md +0 -0
  39. {dirsql-0.3.55 → dirsql-0.3.56}/docs/index.md +0 -0
  40. {dirsql-0.3.55 → dirsql-0.3.56}/docs/migrations.md +0 -0
  41. {dirsql-0.3.55 → dirsql-0.3.56}/docs/package.json +0 -0
  42. {dirsql-0.3.55 → dirsql-0.3.56}/docs/playwright.config.ts +0 -0
  43. {dirsql-0.3.55 → dirsql-0.3.56}/docs/pnpm-lock.yaml +0 -0
  44. {dirsql-0.3.55 → dirsql-0.3.56}/docs/pnpm-workspace.yaml +0 -0
  45. {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/integration/home.spec.ts +0 -0
  46. {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/integration/language-flag.spec.ts +0 -0
  47. {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/integration/sidebar.spec.ts +0 -0
  48. {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/unit/config.test.ts +0 -0
  49. {dirsql-0.3.55 → dirsql-0.3.56}/docs/tests/unit/lang.test.ts +0 -0
  50. {dirsql-0.3.55 → dirsql-0.3.56}/docs/vitest.config.ts +0 -0
  51. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/README.md +0 -0
  52. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/conftest.py +0 -0
  53. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.claude/CLAUDE.md +0 -0
  54. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.vitepress/config.ts +0 -0
  55. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  56. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  57. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/AGENTS.md +0 -0
  58. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/api/index.md +0 -0
  59. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/config.md +0 -0
  60. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/http-api.md +0 -0
  61. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/index.md +0 -0
  62. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/init.md +0 -0
  63. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/cli/server.md +0 -0
  64. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/getting-started.md +0 -0
  65. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/async.md +0 -0
  66. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/crdt.md +0 -0
  67. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/persistence.md +0 -0
  68. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/querying.md +0 -0
  69. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/tables.md +0 -0
  70. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/guide/watching.md +0 -0
  71. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/index.md +0 -0
  72. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/migrations.md +0 -0
  73. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/package.json +0 -0
  74. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/playwright.config.ts +0 -0
  75. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/pnpm-lock.yaml +0 -0
  76. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/pnpm-workspace.yaml +0 -0
  77. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  78. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  79. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  80. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/unit/config.test.ts +0 -0
  81. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  82. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/docs/vitest.config.ts +0 -0
  83. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/src/lib.rs +0 -0
  84. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/__init__.py +0 -0
  85. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/binding/__init__.py +0 -0
  86. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/conftest.py +0 -0
  87. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/e2e/__init__.py +0 -0
  88. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/integration/__init__.py +0 -0
  89. {dirsql-0.3.55 → dirsql-0.3.56}/packages/python/tests/smoke/__init__.py +0 -0
  90. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/Cargo.toml +0 -0
  91. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/README.md +0 -0
  92. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/db_bench.rs +0 -0
  93. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/differ_bench.rs +0 -0
  94. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/matcher_bench.rs +0 -0
  95. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/benches/scanner_bench.rs +0 -0
  96. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/api/index.md +0 -0
  97. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/config.md +0 -0
  98. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/http-api.md +0 -0
  99. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/index.md +0 -0
  100. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/init.md +0 -0
  101. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/cli/server.md +0 -0
  102. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/getting-started.md +0 -0
  103. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/async.md +0 -0
  104. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/crdt.md +0 -0
  105. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/persistence.md +0 -0
  106. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/querying.md +0 -0
  107. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/tables.md +0 -0
  108. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/guide/watching.md +0 -0
  109. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/index.md +0 -0
  110. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/docs/migrations.md +0 -0
  111. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/bin/dirsql.rs +0 -0
  112. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/init.rs +0 -0
  113. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/mod.rs +0 -0
  114. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/router.rs +0 -0
  115. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/serialize.rs +0 -0
  116. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/cli/server.rs +0 -0
  117. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/command.rs +0 -0
  118. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/config.rs +0 -0
  119. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/differ.rs +0 -0
  120. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/lib.rs +0 -0
  121. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/matcher.rs +0 -0
  122. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/scanner.rs +0 -0
  123. {dirsql-0.3.55 → dirsql-0.3.56}/packages/rust/src/watcher.rs +0 -0
  124. {dirsql-0.3.55 → dirsql-0.3.56}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.55"
503
+ version = "0.3.56"
504
504
  dependencies = [
505
505
  "dirsql",
506
506
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.55
3
+ Version: 0.3.56
4
4
  Requires-Dist: pytest>=8 ; extra == 'dev'
5
5
  Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
6
  Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
@@ -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.55"
7
+ version = "0.3.56"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "command": "uv run python -m pytest tests/e2e/ -x -q",
3
- "ran_at": 1783093952,
3
+ "ran_at": 1783101421,
4
4
  "exit_code": 0,
5
- "commit": "014b0b4cd43e639f93ff6258c46c98ff0cd10e49"
5
+ "commit": "557de9e56c14139d4d35b6ba0aed13c601497d4d"
6
6
  }
@@ -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
- self.conn.execute(&sql, param_refs.as_slice())?;
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 = self.conn.execute(&sql, [file_path])?;
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
- pub const SCHEMA_VERSION: &str = "1";
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