dirsql 0.3.55__tar.gz → 0.3.57__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.57}/Cargo.lock +1 -1
  2. {dirsql-0.3.55 → dirsql-0.3.57}/PKG-INFO +1 -1
  3. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/db.rs +459 -3
  6. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/persist.rs +110 -7
  7. {dirsql-0.3.55 → dirsql-0.3.57}/Cargo.toml +0 -0
  8. {dirsql-0.3.55 → dirsql-0.3.57}/README.md +0 -0
  9. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/__init__.py +0 -0
  10. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/_async.py +0 -0
  11. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/_dirsql.pyi +0 -0
  12. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/__init__.py +0 -0
  13. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/binary_path.py +0 -0
  14. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/interpret/__init__.py +0 -0
  15. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/is_windows.py +0 -0
  16. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/main.py +0 -0
  17. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/resolve_config_extensions.py +0 -0
  18. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/py.typed +0 -0
  19. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/resolve_config_extensions.py +0 -0
  20. {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/resolve_extension.py +0 -0
  21. {dirsql-0.3.55 → dirsql-0.3.57}/docs/.claude/CLAUDE.md +0 -0
  22. {dirsql-0.3.55 → dirsql-0.3.57}/docs/.vitepress/config.ts +0 -0
  23. {dirsql-0.3.55 → dirsql-0.3.57}/docs/.vitepress/theme/index.ts +0 -0
  24. {dirsql-0.3.55 → dirsql-0.3.57}/docs/.vitepress/theme/lang.ts +0 -0
  25. {dirsql-0.3.55 → dirsql-0.3.57}/docs/AGENTS.md +0 -0
  26. {dirsql-0.3.55 → dirsql-0.3.57}/docs/api/index.md +0 -0
  27. {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/config.md +0 -0
  28. {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/http-api.md +0 -0
  29. {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/index.md +0 -0
  30. {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/init.md +0 -0
  31. {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/server.md +0 -0
  32. {dirsql-0.3.55 → dirsql-0.3.57}/docs/getting-started.md +0 -0
  33. {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/async.md +0 -0
  34. {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/crdt.md +0 -0
  35. {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/persistence.md +0 -0
  36. {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/querying.md +0 -0
  37. {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/tables.md +0 -0
  38. {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/watching.md +0 -0
  39. {dirsql-0.3.55 → dirsql-0.3.57}/docs/index.md +0 -0
  40. {dirsql-0.3.55 → dirsql-0.3.57}/docs/migrations.md +0 -0
  41. {dirsql-0.3.55 → dirsql-0.3.57}/docs/package.json +0 -0
  42. {dirsql-0.3.55 → dirsql-0.3.57}/docs/playwright.config.ts +0 -0
  43. {dirsql-0.3.55 → dirsql-0.3.57}/docs/pnpm-lock.yaml +0 -0
  44. {dirsql-0.3.55 → dirsql-0.3.57}/docs/pnpm-workspace.yaml +0 -0
  45. {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/integration/home.spec.ts +0 -0
  46. {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/integration/language-flag.spec.ts +0 -0
  47. {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/integration/sidebar.spec.ts +0 -0
  48. {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/unit/config.test.ts +0 -0
  49. {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/unit/lang.test.ts +0 -0
  50. {dirsql-0.3.55 → dirsql-0.3.57}/docs/vitest.config.ts +0 -0
  51. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/README.md +0 -0
  52. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/conftest.py +0 -0
  53. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.claude/CLAUDE.md +0 -0
  54. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.vitepress/config.ts +0 -0
  55. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  56. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  57. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/AGENTS.md +0 -0
  58. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/api/index.md +0 -0
  59. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/config.md +0 -0
  60. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/http-api.md +0 -0
  61. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/index.md +0 -0
  62. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/init.md +0 -0
  63. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/server.md +0 -0
  64. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/getting-started.md +0 -0
  65. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/async.md +0 -0
  66. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/crdt.md +0 -0
  67. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/persistence.md +0 -0
  68. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/querying.md +0 -0
  69. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/tables.md +0 -0
  70. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/watching.md +0 -0
  71. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/index.md +0 -0
  72. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/migrations.md +0 -0
  73. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/package.json +0 -0
  74. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/playwright.config.ts +0 -0
  75. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/pnpm-lock.yaml +0 -0
  76. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/pnpm-workspace.yaml +0 -0
  77. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  78. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  79. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  80. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/unit/config.test.ts +0 -0
  81. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  82. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/vitest.config.ts +0 -0
  83. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/src/lib.rs +0 -0
  84. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/__init__.py +0 -0
  85. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/binding/__init__.py +0 -0
  86. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/conftest.py +0 -0
  87. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/e2e/__init__.py +0 -0
  88. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/integration/__init__.py +0 -0
  89. {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/smoke/__init__.py +0 -0
  90. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/Cargo.toml +0 -0
  91. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/README.md +0 -0
  92. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/db_bench.rs +0 -0
  93. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/differ_bench.rs +0 -0
  94. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/matcher_bench.rs +0 -0
  95. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/scanner_bench.rs +0 -0
  96. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/api/index.md +0 -0
  97. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/config.md +0 -0
  98. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/http-api.md +0 -0
  99. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/index.md +0 -0
  100. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/init.md +0 -0
  101. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/server.md +0 -0
  102. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/getting-started.md +0 -0
  103. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/async.md +0 -0
  104. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/crdt.md +0 -0
  105. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/persistence.md +0 -0
  106. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/querying.md +0 -0
  107. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/tables.md +0 -0
  108. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/watching.md +0 -0
  109. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/index.md +0 -0
  110. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/migrations.md +0 -0
  111. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/bin/dirsql.rs +0 -0
  112. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/init.rs +0 -0
  113. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/mod.rs +0 -0
  114. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/router.rs +0 -0
  115. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/serialize.rs +0 -0
  116. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/server.rs +0 -0
  117. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/command.rs +0 -0
  118. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/config.rs +0 -0
  119. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/differ.rs +0 -0
  120. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/lib.rs +0 -0
  121. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/matcher.rs +0 -0
  122. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/scanner.rs +0 -0
  123. {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/watcher.rs +0 -0
  124. {dirsql-0.3.55 → dirsql-0.3.57}/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.57"
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.57
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.57"
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": 1783105847,
4
4
  "exit_code": 0,
5
- "commit": "014b0b4cd43e639f93ff6258c46c98ff0cd10e49"
5
+ "commit": "d94d40b0448b03171f2e8ffd60635575da1353d5"
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,122 @@ 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.
304
+ ///
305
+ /// Stage 2 (epic #358): row ownership is read from the mapping, not the
306
+ /// injected `_dirsql_file_path` column (now write-only). The user rows to
307
+ /// drop are those whose `rowid` the mapping attributes to `file_path` under
308
+ /// `table`; the mapping rows are then removed in the same transaction.
240
309
  pub fn delete_rows_by_file(&self, table: &str, file_path: &str) -> Result<usize> {
241
310
  validate_identifier(table)?;
242
- let sql = format!("DELETE FROM {} WHERE _dirsql_file_path = ?1", table);
243
- let count = self.conn.execute(&sql, [file_path])?;
311
+ let tx = self.conn.unchecked_transaction()?;
312
+ let sql = format!(
313
+ "DELETE FROM {} WHERE rowid IN \
314
+ (SELECT rowid_ref FROM _dirsql_internal_rows \
315
+ WHERE table_name = ?1 AND file_path = ?2)",
316
+ table
317
+ );
318
+ let count = tx.execute(&sql, rusqlite::params![table, file_path])?;
319
+ tx.execute(
320
+ "DELETE FROM _dirsql_internal_rows WHERE table_name = ?1 AND file_path = ?2",
321
+ rusqlite::params![table, file_path],
322
+ )?;
323
+ tx.commit()?;
244
324
  Ok(count)
245
325
  }
246
326
 
327
+ /// Debug/test equivalence guard for the dual-write mirror (epic #358).
328
+ ///
329
+ /// Asserts that the `_dirsql_internal_rows` mapping for `table` exactly
330
+ /// matches the column-derived tracking state of the live user rows: every
331
+ /// user row's `(rowid, _dirsql_file_path, _dirsql_row_index)` triple must
332
+ /// have one corresponding mapping row `(rowid_ref, file_path, row_index)`,
333
+ /// and vice versa. Returns [`DbError::SchemaMismatch`] describing the drift
334
+ /// otherwise. While the injected columns remain authoritative (stages 1–2),
335
+ /// this is the guard that the new bookkeeping never diverges.
336
+ ///
337
+ /// Not valid for `WITHOUT ROWID` tables (they have no `rowid` column).
338
+ pub fn check_row_mapping_equivalence(&self, table: &str) -> Result<()> {
339
+ validate_identifier(table)?;
340
+ let mut column_state = self.column_tracking_triples(table)?;
341
+ let mut mapping_state = self.mapping_triples(table)?;
342
+ column_state.sort();
343
+ mapping_state.sort();
344
+ if column_state != mapping_state {
345
+ return Err(DbError::SchemaMismatch(format!(
346
+ "row mapping drift for table {table}: \
347
+ column-derived {column_state:?} != mapping-derived {mapping_state:?}"
348
+ )));
349
+ }
350
+ Ok(())
351
+ }
352
+
353
+ /// Column-derived tracking triples for `table`: `(rowid,
354
+ /// _dirsql_file_path, _dirsql_row_index)` for every live user row.
355
+ fn column_tracking_triples(&self, table: &str) -> Result<Vec<(i64, String, i64)>> {
356
+ let sql = format!(
357
+ "SELECT rowid, _dirsql_file_path, _dirsql_row_index FROM {}",
358
+ table
359
+ );
360
+ let mut stmt = self.conn.prepare(&sql)?;
361
+ let rows = stmt.query_map([], |row| {
362
+ Ok((
363
+ row.get::<_, i64>(0)?,
364
+ row.get::<_, String>(1)?,
365
+ row.get::<_, i64>(2)?,
366
+ ))
367
+ })?;
368
+ let mut out = Vec::new();
369
+ for row in rows {
370
+ out.push(row?);
371
+ }
372
+ Ok(out)
373
+ }
374
+
375
+ /// Mapping-derived tracking triples for `table`: `(rowid_ref, file_path,
376
+ /// row_index)` from `_dirsql_internal_rows`.
377
+ fn mapping_triples(&self, table: &str) -> Result<Vec<(i64, String, i64)>> {
378
+ let mut stmt = self.conn.prepare(
379
+ "SELECT rowid_ref, file_path, row_index FROM _dirsql_internal_rows \
380
+ WHERE table_name = ?1",
381
+ )?;
382
+ let rows = stmt.query_map([table], |row| {
383
+ Ok((
384
+ row.get::<_, i64>(0)?,
385
+ row.get::<_, String>(1)?,
386
+ row.get::<_, i64>(2)?,
387
+ ))
388
+ })?;
389
+ let mut out = Vec::new();
390
+ for row in rows {
391
+ out.push(row?);
392
+ }
393
+ Ok(out)
394
+ }
395
+
247
396
  /// Query the database, returning rows as a list of column-name -> value maps.
248
397
  ///
249
398
  /// Rejects any statement that SQLite itself classifies as a write
@@ -497,6 +646,16 @@ fn is_virtual_table_ddl(ddl: &str) -> bool {
497
646
  normalized.to_uppercase().contains("CREATE VIRTUAL TABLE")
498
647
  }
499
648
 
649
+ /// True if `ddl` declares a `WITHOUT ROWID` table. Such tables have no rowid,
650
+ /// so `last_insert_rowid()` cannot identify an inserted row and the
651
+ /// `_dirsql_internal_rows.rowid_ref` mapping (epic #358) is meaningless. Stage
652
+ /// 1 warns; stage 3 rejects. Whitespace is normalized so `WITHOUT ROWID`
653
+ /// and newline-separated forms are still detected.
654
+ fn is_without_rowid_ddl(ddl: &str) -> bool {
655
+ let normalized = ddl.split_whitespace().collect::<Vec<_>>().join(" ");
656
+ normalized.to_uppercase().contains("WITHOUT ROWID")
657
+ }
658
+
500
659
  impl From<rusqlite::types::Value> for Value {
501
660
  fn from(v: rusqlite::types::Value) -> Self {
502
661
  match v {
@@ -1323,4 +1482,301 @@ mod tests {
1323
1482
  "CREATE TABLE IF NOT EXISTS x (a TEXT)"
1324
1483
  ));
1325
1484
  }
1485
+
1486
+ // --- _dirsql_internal_rows mapping (epic #358, stage 1) ---
1487
+
1488
+ /// Read the raw mapping rows for a table, ordered for stable assertions.
1489
+ fn mapping_rows(db: &Db, table: &str) -> Vec<(String, i64, i64)> {
1490
+ let mut stmt = db
1491
+ .conn
1492
+ .prepare(
1493
+ "SELECT file_path, row_index, rowid_ref FROM _dirsql_internal_rows \
1494
+ WHERE table_name = ?1 ORDER BY rowid_ref",
1495
+ )
1496
+ .unwrap();
1497
+ stmt.query_map([table], |r| {
1498
+ Ok((
1499
+ r.get::<_, String>(0)?,
1500
+ r.get::<_, i64>(1)?,
1501
+ r.get::<_, i64>(2)?,
1502
+ ))
1503
+ })
1504
+ .unwrap()
1505
+ .map(|r| r.unwrap())
1506
+ .collect()
1507
+ }
1508
+
1509
+ #[test]
1510
+ fn ensure_internal_rows_table_is_idempotent() {
1511
+ let db = Db::new().unwrap();
1512
+ // Db::new already created it; calling again must not error.
1513
+ ensure_internal_rows_table(&db.conn).unwrap();
1514
+ ensure_internal_rows_table(&db.conn).unwrap();
1515
+ // The table and its by-file index both exist.
1516
+ let table_count: i64 = db
1517
+ .conn
1518
+ .query_row(
1519
+ "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1520
+ [INTERNAL_ROWS_TABLE],
1521
+ |r| r.get(0),
1522
+ )
1523
+ .unwrap();
1524
+ assert_eq!(table_count, 1);
1525
+ let index_count: i64 = db
1526
+ .conn
1527
+ .query_row(
1528
+ "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='_dirsql_internal_rows_by_file'",
1529
+ [],
1530
+ |r| r.get(0),
1531
+ )
1532
+ .unwrap();
1533
+ assert_eq!(index_count, 1);
1534
+ }
1535
+
1536
+ #[test]
1537
+ fn insert_row_records_mapping() {
1538
+ let db = Db::new().unwrap();
1539
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1540
+ db.insert_row(
1541
+ "t",
1542
+ &HashMap::from([("id".into(), Value::Text("a".into()))]),
1543
+ "f.jsonl",
1544
+ 3,
1545
+ )
1546
+ .unwrap();
1547
+
1548
+ let rows = mapping_rows(&db, "t");
1549
+ assert_eq!(rows.len(), 1);
1550
+ assert_eq!(rows[0].0, "f.jsonl");
1551
+ assert_eq!(rows[0].1, 3);
1552
+ // The captured rowid_ref points at the user row.
1553
+ let user_rowid: i64 = db
1554
+ .conn
1555
+ .query_row("SELECT rowid FROM t", [], |r| r.get(0))
1556
+ .unwrap();
1557
+ assert_eq!(rows[0].2, user_rowid);
1558
+ }
1559
+
1560
+ #[test]
1561
+ fn insert_row_captures_user_declared_rowid_alias() {
1562
+ // A user-declared `INTEGER PRIMARY KEY` is a rowid alias: the inserted
1563
+ // value becomes the rowid, and `last_insert_rowid()` must capture it.
1564
+ let db = Db::new().unwrap();
1565
+ db.create_table("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
1566
+ .unwrap();
1567
+ db.insert_row(
1568
+ "t",
1569
+ &HashMap::from([
1570
+ ("id".into(), Value::Integer(42)),
1571
+ ("name".into(), Value::Text("x".into())),
1572
+ ]),
1573
+ "f.json",
1574
+ 0,
1575
+ )
1576
+ .unwrap();
1577
+
1578
+ let rows = mapping_rows(&db, "t");
1579
+ assert_eq!(rows.len(), 1);
1580
+ assert_eq!(rows[0].2, 42, "rowid_ref must be the user-declared rowid");
1581
+ db.check_row_mapping_equivalence("t").unwrap();
1582
+ }
1583
+
1584
+ #[test]
1585
+ fn delete_rows_by_file_removes_mapping() {
1586
+ let db = Db::new().unwrap();
1587
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1588
+ for (i, file) in ["a.jsonl", "a.jsonl", "b.jsonl"].iter().enumerate() {
1589
+ db.insert_row(
1590
+ "t",
1591
+ &HashMap::from([("id".into(), Value::Text(i.to_string()))]),
1592
+ file,
1593
+ i,
1594
+ )
1595
+ .unwrap();
1596
+ }
1597
+ db.delete_rows_by_file("t", "a.jsonl").unwrap();
1598
+
1599
+ // Only b.jsonl's mapping survives, in lockstep with the user rows.
1600
+ let rows = mapping_rows(&db, "t");
1601
+ assert_eq!(rows.len(), 1);
1602
+ assert_eq!(rows[0].0, "b.jsonl");
1603
+ db.check_row_mapping_equivalence("t").unwrap();
1604
+ }
1605
+
1606
+ #[test]
1607
+ fn delete_rows_by_file_reads_mapping_not_columns() {
1608
+ // Stage 2 (epic #358): delete-by-file resolves ownership through the
1609
+ // mapping, not the now-write-only `_dirsql_file_path` column. Corrupt
1610
+ // the column so it disagrees with the mapping and confirm the delete
1611
+ // still follows the mapping.
1612
+ let db = Db::new().unwrap();
1613
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1614
+ db.insert_row(
1615
+ "t",
1616
+ &HashMap::from([("id".into(), Value::Text("a".into()))]),
1617
+ "real.json",
1618
+ 0,
1619
+ )
1620
+ .unwrap();
1621
+ // Desync the write-only column from the mapping.
1622
+ db.conn
1623
+ .execute("UPDATE t SET _dirsql_file_path = 'wrong.json'", [])
1624
+ .unwrap();
1625
+
1626
+ // Deleting by the mapping's file removes the row; deleting by the
1627
+ // stale column value is a no-op.
1628
+ assert_eq!(db.delete_rows_by_file("t", "wrong.json").unwrap(), 0);
1629
+ assert_eq!(db.delete_rows_by_file("t", "real.json").unwrap(), 1);
1630
+ let remaining: i64 = db
1631
+ .conn
1632
+ .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
1633
+ .unwrap();
1634
+ assert_eq!(remaining, 0);
1635
+ db.check_row_mapping_equivalence("t").unwrap();
1636
+ }
1637
+
1638
+ #[test]
1639
+ fn delete_rows_by_file_is_scoped_to_its_table() {
1640
+ // The mapping subquery is keyed on (table_name, file_path): a delete on
1641
+ // one table must not touch another table's rows for the same file path,
1642
+ // even when they share a rowid.
1643
+ let db = Db::new().unwrap();
1644
+ db.create_table("CREATE TABLE t1 (id TEXT)").unwrap();
1645
+ db.create_table("CREATE TABLE t2 (id TEXT)").unwrap();
1646
+ db.insert_row(
1647
+ "t1",
1648
+ &HashMap::from([("id".into(), Value::Text("x".into()))]),
1649
+ "shared.json",
1650
+ 0,
1651
+ )
1652
+ .unwrap();
1653
+ db.insert_row(
1654
+ "t2",
1655
+ &HashMap::from([("id".into(), Value::Text("y".into()))]),
1656
+ "shared.json",
1657
+ 0,
1658
+ )
1659
+ .unwrap();
1660
+
1661
+ assert_eq!(db.delete_rows_by_file("t1", "shared.json").unwrap(), 1);
1662
+ let t2_rows: i64 = db
1663
+ .conn
1664
+ .query_row("SELECT COUNT(*) FROM t2", [], |r| r.get(0))
1665
+ .unwrap();
1666
+ assert_eq!(t2_rows, 1, "t2's row for the same path must be untouched");
1667
+ db.check_row_mapping_equivalence("t1").unwrap();
1668
+ db.check_row_mapping_equivalence("t2").unwrap();
1669
+ }
1670
+
1671
+ #[test]
1672
+ fn failed_row_insert_leaves_no_mapping_row() {
1673
+ // Transactional coupling, forward direction: when the user-row insert
1674
+ // fails, its mapping row must not be written either.
1675
+ let db = Db::new().unwrap();
1676
+ db.create_table("CREATE TABLE t (id TEXT UNIQUE)").unwrap();
1677
+ db.insert_row(
1678
+ "t",
1679
+ &HashMap::from([("id".into(), Value::Text("dup".into()))]),
1680
+ "a.json",
1681
+ 0,
1682
+ )
1683
+ .unwrap();
1684
+ // Second insert of the same UNIQUE value fails.
1685
+ let err = db
1686
+ .insert_row(
1687
+ "t",
1688
+ &HashMap::from([("id".into(), Value::Text("dup".into()))]),
1689
+ "b.json",
1690
+ 0,
1691
+ )
1692
+ .unwrap_err();
1693
+ assert!(matches!(err, DbError::Sqlite(_)), "got: {err}");
1694
+
1695
+ let rows = mapping_rows(&db, "t");
1696
+ assert_eq!(
1697
+ rows.len(),
1698
+ 1,
1699
+ "the failed insert must not add a mapping row"
1700
+ );
1701
+ assert_eq!(rows[0].0, "a.json");
1702
+ db.check_row_mapping_equivalence("t").unwrap();
1703
+ }
1704
+
1705
+ #[test]
1706
+ fn failed_mapping_insert_rolls_back_row_insert() {
1707
+ // Transactional coupling, reverse direction: if the mapping insert
1708
+ // fails, the user-row insert must roll back too (nothing left behind).
1709
+ let db = Db::new().unwrap();
1710
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1711
+ // Remove the mapping table so the second statement in the transaction
1712
+ // errors with "no such table".
1713
+ db.conn
1714
+ .execute("DROP TABLE _dirsql_internal_rows", [])
1715
+ .unwrap();
1716
+ let err = db
1717
+ .insert_row(
1718
+ "t",
1719
+ &HashMap::from([("id".into(), Value::Text("x".into()))]),
1720
+ "a.json",
1721
+ 0,
1722
+ )
1723
+ .unwrap_err();
1724
+ assert!(matches!(err, DbError::Sqlite(_)), "got: {err}");
1725
+
1726
+ let count: i64 = db
1727
+ .conn
1728
+ .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
1729
+ .unwrap();
1730
+ assert_eq!(count, 0, "the user row must have rolled back");
1731
+ }
1732
+
1733
+ #[test]
1734
+ fn check_row_mapping_equivalence_detects_drift() {
1735
+ let db = Db::new().unwrap();
1736
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1737
+ db.insert_row(
1738
+ "t",
1739
+ &HashMap::from([("id".into(), Value::Text("a".into()))]),
1740
+ "f.json",
1741
+ 0,
1742
+ )
1743
+ .unwrap();
1744
+ db.check_row_mapping_equivalence("t").unwrap();
1745
+
1746
+ // Corrupt the mapping directly, bypassing the dual-write path.
1747
+ db.conn
1748
+ .execute("DELETE FROM _dirsql_internal_rows", [])
1749
+ .unwrap();
1750
+ let err = db.check_row_mapping_equivalence("t").unwrap_err();
1751
+ assert!(matches!(err, DbError::SchemaMismatch(_)), "got: {err}");
1752
+ assert!(err.to_string().contains("row mapping drift"));
1753
+ }
1754
+
1755
+ #[test]
1756
+ fn check_row_mapping_equivalence_rejects_bad_table_name() {
1757
+ let db = Db::new().unwrap();
1758
+ let err = db.check_row_mapping_equivalence("bad name").unwrap_err();
1759
+ assert!(matches!(err, DbError::InvalidIdentifier(_)), "got: {err}");
1760
+ }
1761
+
1762
+ #[test]
1763
+ fn create_table_allows_without_rowid_and_warns() {
1764
+ // Stage 1 only warns (via eprintln); the table is still created.
1765
+ let db = Db::new().unwrap();
1766
+ db.create_table("CREATE TABLE t (id TEXT PRIMARY KEY) WITHOUT ROWID")
1767
+ .unwrap();
1768
+ let rows = db.query("SELECT * FROM t").unwrap();
1769
+ assert_eq!(rows.len(), 0);
1770
+ }
1771
+
1772
+ #[test]
1773
+ fn is_without_rowid_ddl_detects_variants() {
1774
+ assert!(is_without_rowid_ddl(
1775
+ "CREATE TABLE t (id TEXT PRIMARY KEY) WITHOUT ROWID"
1776
+ ));
1777
+ assert!(is_without_rowid_ddl(
1778
+ "create table t (id text primary key)\n without rowid"
1779
+ ));
1780
+ assert!(!is_without_rowid_ddl("CREATE TABLE t (id TEXT)"));
1781
+ }
1326
1782
  }
@@ -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,12 +377,22 @@ 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
 
378
- /// Look up the existing `dirsql_*` tracking column rows for one file. Used
379
- /// on warm start to rebuild the in-memory `file_rows` cache that the
380
- /// watcher's diffing path requires.
387
+ /// Look up the row store's rows for one file, ordered by row index. Used on
388
+ /// warm start to rebuild the in-memory `file_rows` cache that the watcher's
389
+ /// diffing path requires.
390
+ ///
391
+ /// Stage 2 (epic #358): ownership and ordering are read from the
392
+ /// `_dirsql_internal_rows` mapping (joined on `rowid`), not the injected
393
+ /// `_dirsql_file_path` / `_dirsql_row_index` columns (now write-only). User
394
+ /// columns are qualified with the table alias so a user column named like a
395
+ /// mapping column stays unambiguous.
381
396
  pub fn read_rows_for_file(
382
397
  conn: &Connection,
383
398
  table: &str,
@@ -386,17 +401,19 @@ pub fn read_rows_for_file(
386
401
  ) -> rusqlite::Result<Vec<HashMap<String, crate::db::Value>>> {
387
402
  let mut col_list = user_columns
388
403
  .iter()
389
- .map(|c| format!("\"{c}\""))
404
+ .map(|c| format!("t.\"{c}\""))
390
405
  .collect::<Vec<_>>()
391
406
  .join(", ");
392
407
  if col_list.is_empty() {
393
408
  col_list = "1".to_string();
394
409
  }
395
410
  let sql = format!(
396
- "SELECT {col_list} FROM \"{table}\" WHERE _dirsql_file_path = ?1 ORDER BY _dirsql_row_index"
411
+ "SELECT {col_list} FROM \"{table}\" AS t \
412
+ JOIN _dirsql_internal_rows AS m ON m.rowid_ref = t.rowid \
413
+ WHERE m.table_name = ?1 AND m.file_path = ?2 ORDER BY m.row_index"
397
414
  );
398
415
  let mut stmt = conn.prepare(&sql)?;
399
- let rows = stmt.query_map(params![rel_path], |row| {
416
+ let rows = stmt.query_map(params![table, rel_path], |row| {
400
417
  let mut map = HashMap::new();
401
418
  for (i, name) in user_columns.iter().enumerate() {
402
419
  let v: rusqlite::types::Value = row.get(i)?;
@@ -530,9 +547,26 @@ mod tests {
530
547
  fn drop_user_tables_clears_user_data_and_files_index() {
531
548
  let conn = Connection::open_in_memory().unwrap();
532
549
  create_sidecar_tables(&conn).unwrap();
550
+ // The internal row mapping (epic #358) is created by `Db::open` in
551
+ // production; declare it inline here since this test uses a raw
552
+ // connection (calling the `db` helper would break unit isolation).
553
+ conn.execute(
554
+ "CREATE TABLE _dirsql_internal_rows (
555
+ table_name TEXT NOT NULL, file_path TEXT NOT NULL,
556
+ row_index INTEGER NOT NULL, rowid_ref INTEGER NOT NULL)",
557
+ [],
558
+ )
559
+ .unwrap();
533
560
  conn.execute("CREATE TABLE rows (x TEXT)", []).unwrap();
534
561
  conn.execute("INSERT INTO rows (x) VALUES ('a')", [])
535
562
  .unwrap();
563
+ // A stale mapping row that a cold rebuild must wipe.
564
+ conn.execute(
565
+ "INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
566
+ VALUES ('rows', 'a.csv', 0, 1)",
567
+ [],
568
+ )
569
+ .unwrap();
536
570
  let stat = FileStat {
537
571
  size: 1,
538
572
  mtime_ns: 1,
@@ -554,6 +588,13 @@ mod tests {
554
588
  .unwrap();
555
589
  assert_eq!(table_count, 0);
556
590
  assert!(read_cached_files(&conn).unwrap().is_empty());
591
+ // The internal row mapping is wiped too, ready for a fresh re-ingest.
592
+ let mapping_count: i64 = conn
593
+ .query_row("SELECT COUNT(*) FROM _dirsql_internal_rows", [], |r| {
594
+ r.get(0)
595
+ })
596
+ .unwrap();
597
+ assert_eq!(mapping_count, 0);
557
598
  // Meta is preserved by drop_user_tables -- we replace it explicitly.
558
599
  let m = read_meta(&conn).unwrap();
559
600
  assert_eq!(m.get("x"), Some(&"y".to_string()));
@@ -640,6 +681,18 @@ mod tests {
640
681
  assert_eq!(system_time_to_ns(None), 0);
641
682
  }
642
683
 
684
+ /// Declare the internal mapping table on a raw connection (the `db` helper
685
+ /// would break unit isolation). Stage 2 reads through this table.
686
+ fn create_internal_rows_table(conn: &Connection) {
687
+ conn.execute(
688
+ "CREATE TABLE _dirsql_internal_rows (
689
+ table_name TEXT NOT NULL, file_path TEXT NOT NULL,
690
+ row_index INTEGER NOT NULL, rowid_ref INTEGER NOT NULL)",
691
+ [],
692
+ )
693
+ .unwrap();
694
+ }
695
+
643
696
  #[test]
644
697
  fn read_rows_for_file_handles_empty_user_columns() {
645
698
  // With no user columns the SELECT list collapses to `1`, so the
@@ -650,11 +703,19 @@ mod tests {
650
703
  [],
651
704
  )
652
705
  .unwrap();
706
+ create_internal_rows_table(&conn);
653
707
  conn.execute(
654
708
  "INSERT INTO rows (_dirsql_file_path, _dirsql_row_index) VALUES ('f.csv', 0), ('f.csv', 1)",
655
709
  [],
656
710
  )
657
711
  .unwrap();
712
+ // Map the two inserted rows (rowids 1, 2) to f.csv.
713
+ conn.execute(
714
+ "INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
715
+ VALUES ('rows', 'f.csv', 0, 1), ('rows', 'f.csv', 1, 2)",
716
+ [],
717
+ )
718
+ .unwrap();
658
719
  let rows = read_rows_for_file(&conn, "rows", "f.csv", &[]).unwrap();
659
720
  assert_eq!(rows.len(), 2);
660
721
  assert!(rows[0].is_empty());
@@ -668,17 +729,59 @@ mod tests {
668
729
  [],
669
730
  )
670
731
  .unwrap();
732
+ create_internal_rows_table(&conn);
671
733
  conn.execute(
672
734
  "INSERT INTO rows (col, _dirsql_file_path, _dirsql_row_index) VALUES ('a', 'f.csv', 0), ('b', 'f.csv', 1), ('c', 'g.csv', 0)",
673
735
  [],
674
736
  )
675
737
  .unwrap();
738
+ // rowids 1, 2 belong to f.csv; rowid 3 to g.csv.
739
+ conn.execute(
740
+ "INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
741
+ VALUES ('rows', 'f.csv', 0, 1), ('rows', 'f.csv', 1, 2), ('rows', 'g.csv', 0, 3)",
742
+ [],
743
+ )
744
+ .unwrap();
676
745
  let rows = read_rows_for_file(&conn, "rows", "f.csv", &["col".to_string()]).unwrap();
677
746
  assert_eq!(rows.len(), 2);
678
747
  assert_eq!(rows[0]["col"], crate::db::Value::Text("a".into()));
679
748
  assert_eq!(rows[1]["col"], crate::db::Value::Text("b".into()));
680
749
  }
681
750
 
751
+ #[test]
752
+ fn read_rows_for_file_reads_mapping_not_columns() {
753
+ // Stage 2: ownership + ordering come from the mapping, not the
754
+ // write-only injected columns. Store a row whose `_dirsql_file_path` /
755
+ // `_dirsql_row_index` columns DISAGREE with the mapping, and confirm
756
+ // the read follows the mapping.
757
+ let conn = Connection::open_in_memory().unwrap();
758
+ conn.execute(
759
+ "CREATE TABLE rows (col TEXT, _dirsql_file_path TEXT, _dirsql_row_index INTEGER)",
760
+ [],
761
+ )
762
+ .unwrap();
763
+ create_internal_rows_table(&conn);
764
+ // Column says wrong.csv / index 9; mapping says right.csv / index 0.
765
+ conn.execute(
766
+ "INSERT INTO rows (col, _dirsql_file_path, _dirsql_row_index) VALUES ('a', 'wrong.csv', 9)",
767
+ [],
768
+ )
769
+ .unwrap();
770
+ conn.execute(
771
+ "INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
772
+ VALUES ('rows', 'right.csv', 0, 1)",
773
+ [],
774
+ )
775
+ .unwrap();
776
+ // Querying the mapping's file returns the row...
777
+ let hit = read_rows_for_file(&conn, "rows", "right.csv", &["col".to_string()]).unwrap();
778
+ assert_eq!(hit.len(), 1);
779
+ assert_eq!(hit[0]["col"], crate::db::Value::Text("a".into()));
780
+ // ...and the column's (stale) file returns nothing.
781
+ let miss = read_rows_for_file(&conn, "rows", "wrong.csv", &["col".to_string()]).unwrap();
782
+ assert!(miss.is_empty());
783
+ }
784
+
682
785
  // --- rusqlite error propagation (`?` arms) ---
683
786
  //
684
787
  // Each sidecar accessor runs against a `_dirsql_*` table. Calling it on a
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