Pure-managed .NET library for reading and writing Microsoft Access JET databases — no OleDB, ODBC, or ACE/Jet driver installation required.
AccessWriter supports explicit transactions for atomic multi-row/page operations. All page mutations are buffered in memory until committed or rolled back.
await using var writer = await AccessWriter.OpenAsync("database.mdb");
// Transaction with commit/rollback
await using (var tx = await writer.BeginTransactionAsync())
{
try
{
await writer.InsertRowAsync("Contacts", new object[] { 7, "Grace", "grace@example.com", 90.0m });
await writer.UpdateRowsAsync("Contacts", "ContactID", 2, new Dictionary<string, object> { ["Score"] = 93.5m });
await tx.CommitAsync(); // Writes all changes atomically
}
catch
{
await tx.RollbackAsync(); // Discards all changes if an error occurs
throw;
}
}If you do not call CommitAsync, the transaction is rolled back automatically when disposed.
| ✅ Pure managed .NET | No OleDB, ODBC, or ACE/Jet driver — runs anywhere .NET runs |
| ✅ All Access versions | Jet3 / Jet4 / ACE — Access 97 through Microsoft 365 (.mdb / .accdb) |
| ✅ Read & write | Create databases and tables; insert/update/delete rows; add/drop/rename columns |
| ✅ Typed values | int, DateTime, decimal, Guid, MEMO, OLE, Hyperlink — not just strings |
| ✅ POCO + LINQ streaming | Rows<T>("...").Where(...).Take(...).ToListAsync(ct) over IAsyncEnumerable<T> |
| ✅ Async-first | ValueTask<T> API, OpenAsync(...), await using (IAsyncDisposable), IProgress<T> callbacks |
| ✅ Stream-based I/O | Open from any seekable Stream (files, byte arrays, blobs, embedded resources) |
| ✅ Encryption | Jet3 XOR, Jet4 RC4, ACCDB legacy / AES-128 / Agile (Office Crypto) — all read/write |
| ✅ Schema features | Indexes, primary & foreign keys with referential integrity (cascade update/delete), linked tables (Access + ODBC catalog entries) |
| ✅ Complex columns | Read/write attachments and multi-value columns (ACCDB) |
| ✅ Concurrency | .ldb / .laccdb lockfile + page-level byte-range locks matching the JET/ACE protocol |
| ✅ Transactions | BeginTransactionAsync() → atomic CommitAsync / RollbackAsync via in-memory page journal |
| ✅ Performance | Configurable LRU page cache, optional parallel page reads, streams millions of rows without loading the file |
dotnet add package JetDatabaseWriterInstall-Package JetDatabaseWriterJetDatabaseWriter multi-targets netstandard2.1 and net10.0. The net10.0 asset is selected automatically on .NET 10+ consumers; the netstandard2.1 asset covers everything else:
| Consumer | Minimum version |
|---|---|
| .NET | 5+ |
| .NET Core | 3.0+ |
| Mono | 6.4+ |
| Unity | 2021.2+ |
using JetDatabaseWriter;
using System.Threading;
public class Order
{
public int OrderID { get; set; }
public DateTime OrderDate { get; set; }
public decimal Freight { get; set; }
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using var reader = await AccessReader.OpenAsync("database.mdb", cancellationToken: cts.Token);
List<string> tables = await reader.ListTablesAsync(cts.Token);
Console.WriteLine($"Found {tables.Count} tables: {string.Join(", ", tables)}");
List<Order> orders = await reader.ReadTableAsync<Order>("Orders", maxRows: 100, cts.Token);
foreach (Order o in orders)
Console.WriteLine($"#{o.OrderID} {o.OrderDate:yyyy-MM-dd} {o.Freight:C}");OpenAsync(...) + await using is the recommended pattern.
await using var reader = await AccessReader.OpenAsync("database.mdb", cancellationToken: cts.Token);
await using var writer = await AccessWriter.OpenAsync("database.mdb");Both AccessReader and AccessWriter accept any seekable Stream — useful for byte arrays, Azure Blob Storage, embedded resources, or HTTP downloads.
byte[] bytes = await File.ReadAllBytesAsync("database.mdb");
var ms = new MemoryStream(bytes);
await using var reader = await AccessReader.OpenAsync(ms);By default the stream is disposed with the reader/writer. Pass leaveOpen: true to retain ownership:
var ms = new MemoryStream(File.ReadAllBytes("template.mdb"));
await using (var writer = await AccessWriter.OpenAsync(ms, leaveOpen: true))
{
await writer.InsertRowAsync("Orders", new object[] { 1, "Widget", 9.99m });
}
byte[] modified = ms.ToArray();The stream must be readable and seekable. For
AccessWriter, it must also be writable.
public class Product
{
public int ProductID { get; set; }
public string ProductName { get; set; }
public decimal UnitPrice { get; set; }
public bool Discontinued { get; set; }
}
List<Product> products = await reader.ReadTableAsync<Product>("Products", maxRows: 100, cancellationToken);
decimal total = products.Where(p => !p.Discontinued).Sum(p => p.UnitPrice);Property names are matched to column headers case-insensitively. Unmatched properties keep their default value. The type T must be a class with a parameterless constructor.
DataTable dt = await reader.ReadDataTableAsync("Products", cancellationToken: cancellationToken);
// dt.Columns["ProductID"].DataType == typeof(int)
// dt.Columns["UnitPrice"].DataType == typeof(decimal)
// dt.Columns["Discontinued"].DataType == typeof(bool)List<ColumnMetadata> meta = await reader.GetColumnMetadataAsync("Products", cancellationToken);
foreach (ColumnMetadata col in meta)
{
Type clrType = col.ClrType; // e.g. typeof(int), typeof(string)
string display = col.Size.ToString(); // e.g. "4 bytes", "255 chars", "LVAL"
Console.WriteLine($"{col.Name}: {clrType.Name} ({col.Size})");
}ListIndexesAsync returns the logical indexes declared on a table — primary keys, foreign-key indexes, and ordinary user indexes — parsed directly from the TDEF page chain. Only schema metadata is surfaced; the index B-tree leaf pages are not traversed.
IReadOnlyList<IndexMetadata> indexes = await reader.ListIndexesAsync("Companies", cancellationToken);
foreach (IndexMetadata idx in indexes)
{
string keys = string.Join(", ", idx.Columns.Select(c => c.Name));
Console.WriteLine($"{idx.Name}: {idx.Kind} on ({keys}) unique={idx.IsUnique} fk={idx.IsForeignKey}");
}Multiple logical indexes can share the same physical index — consult IndexMetadata.RealIndexNumber to detect that sharing. The IndexKind enum distinguishes Normal, PrimaryKey, and ForeignKey. Note: Access does not always set the IsUnique flag bit on primary keys (uniqueness is implied by Kind == PrimaryKey).
GetComplexColumnsAsync joins the parent TDEF column descriptors with MSysComplexColumns to expose the per-column ComplexID, the hidden flat child-table name, and the column subtype (Attachment, MultiValue, or VersionHistory).
IReadOnlyList<ComplexColumnInfo> complex = await reader.GetComplexColumnsAsync("Documents", cancellationToken);
foreach (ComplexColumnInfo c in complex)
{
Console.WriteLine($"{c.ColumnName}: {c.Kind} flat={c.FlatTableName} template={c.ComplexTypeName}");
}Returns an empty list for tables without complex columns and for older Jet3 / Jet4 (.mdb) files.
For ACE .accdb files, attachments and multi-value items can be inserted into an existing parent row and read back via spec-compliant APIs:
// Insert an attachment into the row whose Id = 1
await writer.AddAttachmentAsync(
"Documents",
"Files",
new Dictionary<string, object> { ["Id"] = 1 },
new AttachmentInput("notes.txt", File.ReadAllBytes("notes.txt")),
cancellationToken);
// Insert a multi-value tag item
await writer.AddMultiValueItemAsync("Tags", "Items", new Dictionary<string, object> { ["Id"] = 1 }, "red", cancellationToken);
// Read back
IReadOnlyList<AttachmentRecord> attachments = await reader.GetAttachmentsAsync("Documents", "Files", cancellationToken);
IReadOnlyList<(int ConceptualTableId, object? Value)> tags = await reader.GetMultiValueItemsAsync("Tags", "Items", cancellationToken);The parent-row predicate must match exactly one row (zero or multiple matches throw InvalidOperationException). Attachment payloads are wrapped per MS-ACCDB §3.1 (4-byte typeFlag + dataLen + extension + payload, with raw-deflate compression skipped for already-compressed extensions). Payloads larger than the 256-byte inline-OLE cap are pushed onto freshly-allocated LVAL data pages (single-page or chained form) and reassembled by the reader; the upper limit is the 24-bit on-disk LVAL length field (~16 MB per file).
Hyperlink columns are MEMO columns whose TDEF flag byte has the HYPERLINK_FLAG_MASK = 0x80 bit set. Microsoft Access stores the value as a single #-delimited string (displaytext#address#subaddress#screentip); the library round-trips that structure as a strongly-typed Hyperlink record.
await writer.CreateTableAsync("Bookmarks",
[
new("Id", typeof(int)) { IsAutoIncrement = true, IsPrimaryKey = true },
new("Link", typeof(Hyperlink)), // shorthand
// equivalent: new("Link", typeof(string)) { IsHyperlink = true }
]);
await writer.InsertRowAsync("Bookmarks", new object[]
{
DBNull.Value,
new Hyperlink("Docs site", "https://example.com/docs", subAddress: "intro"),
});
await using var reader = await AccessReader.OpenAsync(path);
await foreach (object[] row in reader.Rows("Bookmarks"))
{
var link = (Hyperlink)row[1];
Console.WriteLine($"{link.DisplayText} → {link.Address}#{link.SubAddress}");
}
ColumnMetadata col = (await reader.GetColumnMetadataAsync("Bookmarks"))[1];
// col.IsHyperlink == true
// col.ClrType == typeof(Hyperlink)
// col.TypeName == "Hyperlink"POCO mapping accepts either a Hyperlink property or a plain string property — the conversion runs both ways. Compatibility surfaces (RowsAsStrings, ReadTableAsStringsAsync) continue to yield the raw #-delimited form. See docs/design/hyperlink-format-notes.md for the on-disk layout, escape semantics, and round-trip rules.
// All values as strings
DataTable preview = await reader.ReadTableAsStringsAsync("Products", maxRows: 20, cancellationToken);
// String row access
string firstCell = preview.Rows[0][0].ToString();var progress = new Progress<long>(n => Console.Write($"\r{n:N0} rows"));
await foreach (Product p in reader.Rows<Product>("Products", progress))
Console.WriteLine($"{p.ProductName}: {p.UnitPrice:C}");await foreach (object[] row in reader.Rows("BigTable"))
{
int id = (int)row[0];
decimal val = row[2] == DBNull.Value ? 0m : (decimal)row[2];
}await foreach (string[] row in reader.RowsAsStrings("BigTable"))
Console.WriteLine(string.Join(", ", row));Null values in typed object[] rows surface as DBNull.Value.
The reader exposes each table as an IAsyncEnumerable<T> via Rows, Rows<T>, and RowsAsStrings. Compose with the standard async LINQ operators (Where, Take, Select, ToListAsync, FirstOrDefaultAsync, CountAsync, …) — there is no separate query type and no terminal Execute call.
// Generic POCO result — first match
Order? first = await reader.Rows<Order>("Orders")
.Where(o => o.OrderDate.Year == 2024)
.Take(10)
.FirstOrDefaultAsync(ct);
// Materialize to a list
List<Order> recent = await reader.Rows<Order>("Orders")
.Where(o => o.OrderDate.Year == 2024)
.ToListAsync(ct);
// Object-array chain (no POCO)
int count = await reader.Rows("OrderDetails")
.Where(row => row[3] is decimal p && p > 100m)
.CountAsync(ct);
// String chain — useful for compatibility / CSV-style consumers
await foreach (string[] row in reader.RowsAsStrings("Orders")
.Where(r => r[2].StartsWith("2024"))
.Take(50)
.WithCancellation(ct))
{
Console.WriteLine(string.Join(", ", row));
}Filtering and projection run client-side per row — there is no SQL engine underneath — but enumeration is fully lazy and Take/First short-circuit the underlying page reader.
ReadAllTablesAsync and ReadAllTablesAsStringsAsync materialize every user table in one call and accept a Progress<TableProgress> callback that fires once per table:
Dictionary<string, DataTable> all = await reader.ReadAllTablesAsync(
new Progress<TableProgress>(p => Console.WriteLine($"Reading {p.TableName} ({p.TableIndex + 1}/{p.TableCount})...")),
cancellationToken);All async APIs return ValueTask<T>. Reader/writer instances implement IAsyncDisposable, so prefer await using.
Supports Jet3, Jet4, and ACE formats —
.mdb(Access 97+) or.accdb.
await using var writer = await AccessWriter.OpenAsync("database.mdb");await writer.CreateTableAsync("Contacts", new[]
{
new ColumnDefinition("ContactID", typeof(int)),
new ColumnDefinition("Name", typeof(string), maxLength: 100),
new ColumnDefinition("Email", typeof(string), maxLength: 255),
new ColumnDefinition("Score", typeof(decimal)),
});
await writer.DropTableAsync("Contacts");ColumnDefinition accepts four optional constraints in addition to Name/ClrType/MaxLength:
await writer.CreateTableAsync("Contacts", new[]
{
new ColumnDefinition("ContactID", typeof(int)) { IsAutoIncrement = true, IsNullable = false },
new ColumnDefinition("Name", typeof(string), maxLength: 100) { IsNullable = false },
new ColumnDefinition("Score", typeof(int))
{
DefaultValue = 0,
DefaultValueExpression = "0",
ValidationRule = v => v is int i and >= 0 and <= 100,
ValidationRuleExpression = ">=0 And <=100",
ValidationText = "Score must be between 0 and 100.",
Description = "Test score (0-100).",
},
});| Constraint | Persisted in the file? | Notes |
|---|---|---|
IsNullable |
✅ TDEF flag bit FLAG_NULL_ALLOWED 0x02 |
Restored on reopen; surfaced to readers via ColumnMetadata.IsNullable. |
IsAutoIncrement |
✅ TDEF flag bit FLAG_AUTO_LONG 0x04 |
Supported for byte/short/int/long. Seeded from max(existing) + 1 on first use. |
IsPrimaryKey |
✅ TDEF logical-index entry with index_type = 0x01 |
Shortcut for synthesizing a PK IndexDefinition named "PrimaryKey" from one or more columns (in declaration order). Forces the PK key columns to IsNullable = false on the emitted TDEF. Mixing this with an explicit PK IndexDefinition in the same call throws ArgumentException. Single- and multi-column PKs both participate in live B-tree leaf maintenance (composite-key path). |
DefaultValue |
CLR object substituted for DBNull.Value at insert time on the AccessWriter instance that declared it. For an engine-level default that Microsoft Access also honours, set DefaultValueExpression (it is auto-derived from DefaultValue when omitted). |
|
ValidationRule |
A CLR Func<> cannot be serialized into the file. For an engine-level rule Microsoft Access also enforces, set ValidationRuleExpression. |
|
DefaultValueExpression |
✅ MSysObjects.LvProp (DefaultValue) |
Jet expression string (e.g. "0", "\"hi\"", "=Now()"). Surfaced to readers via ColumnMetadata.DefaultValueExpression. Wins over DefaultValue for persistence. |
ValidationRuleExpression |
✅ MSysObjects.LvProp (ValidationRule) |
Jet expression string (e.g. ">=0 And <=100"). Surfaced via ColumnMetadata.ValidationRuleExpression. |
ValidationText |
✅ MSysObjects.LvProp (ValidationText) |
User-facing message Access shows when ValidationRuleExpression rejects a value. Surfaced via ColumnMetadata.ValidationText. |
Description |
✅ MSysObjects.LvProp (Description) |
Free-text column description shown in Access Design View. Surfaced via ColumnMetadata.Description. Preserved across AddColumnAsync / DropColumnAsync / RenameColumnAsync. |
public class Contact
{
public int ContactID { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public decimal Score { get; set; }
}
await writer.InsertRowAsync("Contacts", new Contact { ContactID = 1, Name = "Alice", Email = "alice@example.com", Score = 95.5m });
await writer.InsertRowsAsync("Contacts", new[]
{
new Contact { ContactID = 2, Name = "Bob", Email = "bob@example.com", Score = 88.0m },
new Contact { ContactID = 3, Name = "Carol", Email = "carol@example.com", Score = 92.3m },
});await writer.InsertRowAsync("Contacts", new object[] { 4, "Dave", "dave@example.com", 77.1m });
await writer.InsertRowsAsync("Contacts", new[]
{
new object[] { 5, "Eve", "eve@example.com", 91.0m },
new object[] { 6, "Frank", "frank@example.com", 85.4m },
});int updated = await writer.UpdateRowsAsync("Contacts", "ContactID", 1,
new Dictionary<string, object> { ["Score"] = 99.9m });
int deleted = await writer.DeleteRowsAsync("Contacts", "ContactID", 3);// Append a new column. Existing rows receive DBNull for the new column.
await writer.AddColumnAsync("Contacts", new ColumnDefinition("Phone", typeof(string), maxLength: 32));
// Rename an existing column. Row data is preserved.
await writer.RenameColumnAsync("Contacts", "Score", "Rating");
// Drop a column. Its data is permanently lost.
await writer.DropColumnAsync("Contacts", "Phone");These operations rewrite the whole table (copy rows to a new schema, then swap the catalog entry). Cost scales with row count.
Linked tables are catalog-only entries that point at data living in another database. The library writes the MSysObjects row; readers (this library, Microsoft Access, etc.) follow the entry to fetch the data on demand.
// Linked Access table (MSysObjects type 4) — references a table in another .mdb / .accdb file.
await writer.CreateLinkedTableAsync(
linkedTableName: "RemoteOrders",
sourceDatabasePath: @"C:\Data\Backend.accdb",
foreignTableName: "Orders");
// Linked ODBC table (MSysObjects type 6) — references a table over an ODBC connection.
// The "ODBC;" prefix is added automatically when omitted.
await writer.CreateLinkedOdbcTableAsync(
linkedTableName: "LinkedSalesOrders",
connectionString: "ODBC;DRIVER={SQL Server};SERVER=db.example.com;DATABASE=Sales;Trusted_Connection=Yes",
foreignTableName: "dbo.Orders");The library only writes the catalog metadata. It does not open the ODBC source itself — reading an ODBC-linked table from this library is not supported. Use
ListLinkedTablesAsync()to enumerate linked entries.
Declare a relationship between two existing tables. The library appends one row per FK column to the MSysRelationships catalog (which Microsoft Access reads to populate the Relationships designer) and, on Jet4 / ACE databases, emits the matching per-TDEF foreign-key logical-index entries on both sides so the relationship is visible to readers immediately. Jet3 .mdb files get only the catalog rows.
Runtime referential integrity is enforced on InsertRowAsync / UpdateRowsAsync / DeleteRowsAsync for any relationship created with EnforceReferentialIntegrity = true (the default); CascadeUpdates and CascadeDeletes honour the cascade flags. See the Limitations section for caveats.
// Single-column FK
await writer.CreateRelationshipAsync(new RelationshipDefinition(
name: "FK_Orders_Customers",
primaryTable: "Customers", // PK side — szReferencedObject
primaryColumn: "CustomerID",
foreignTable: "Orders", // FK side — szObject
foreignColumn: "CustomerID")
{
EnforceReferentialIntegrity = true, // default
CascadeUpdates = false,
CascadeDeletes = false,
});
// Multi-column FK
await writer.CreateRelationshipAsync(new RelationshipDefinition(
name: "FK_OrderItems_Orders",
primaryTable: "Orders",
primaryColumns: new[] { "OrderID", "Region" },
foreignTable: "OrderItems",
foreignColumns: new[] { "OrderID", "Region" }));Requires a database that already contains the
MSysRelationshipscatalog table — every Access-authored.mdb/.accdbdoes, but databases freshly created byAccessWriter.CreateDatabaseAsyncdo not. Open or copy an Access-authored fixture first, orCreateRelationshipAsyncthrowsNotSupportedException.
// Table-level stats (single catalog scan)
foreach (TableStat ts in await reader.GetTableStatsAsync(cancellationToken))
Console.WriteLine($"{ts.Name}: {ts.RowCount:N0} rows, {ts.ColumnCount} cols");
// First table preview as DataTable
DataTable first = await reader.ReadFirstTableAsync(maxRows: 20, cancellationToken);
Console.WriteLine($"First table: {first.TableName}, {first.Rows.Count} rows");
DatabaseStatistics s = await reader.GetStatisticsAsync(cancellationToken);
Console.WriteLine($"Version: {s.Version}");
Console.WriteLine($"Size: {s.DatabaseSizeBytes / 1024 / 1024} MB");
Console.WriteLine($"Tables: {s.TableCount} Rows: {s.TotalRows:N0}");
Console.WriteLine($"Cache hit: {s.PageCacheHitRate}%");The JetDatabaseWriter.Scaffold CLI tool reads the schema of every user table in a JET database and emits one C# entity-model source file per table.
# Positional argument
dotnet run --project JetDatabaseWriter.Scaffold -- Northwind.mdb
# Named options
dotnet run --project JetDatabaseWriter.Scaffold -- --database Northwind.mdb --output ./Entities --namespace MyApp.Models
# Emit records with nullable reference types
dotnet run --project JetDatabaseWriter.Scaffold -- Northwind.mdb --records --nullable
# Password-protected database
dotnet run --project JetDatabaseWriter.Scaffold -- Secure.accdb --password secret| Option | Short | Default | Description |
|---|---|---|---|
--database |
-d |
(positional) | Path to the .mdb or .accdb file |
--output |
-o |
./Models |
Output directory for generated files |
--namespace |
-n |
GeneratedModels |
Namespace for generated classes |
--password |
-p |
— | Database password (for encrypted files) |
--records |
false |
Emit C# record types instead of class |
|
--nullable |
true |
Emit nullable reference types (#nullable enable) |
Given an Orders table with columns OrderID (int), OrderDate (DateTime), and Freight (decimal), the tool generates:
// <auto-generated>
namespace GeneratedModels;
using System;
public sealed class Orders
{
/// <summary>Column: OrderID (Long Integer, 4 bytes).</summary>
public int OrderID { get; set; }
/// <summary>Column: OrderDate (DateTime, 8 bytes).</summary>
public DateTime OrderDate { get; set; }
/// <summary>Column: Freight (Currency, 8 bytes).</summary>
public decimal Freight { get; set; }
}Table and column names are automatically converted to PascalCase C# identifiers — spaces, hyphens, and special characters are cleaned, and C# keywords are escaped.
var options = new AccessReaderOptions("secretPassword")
{
PageCacheSize = 512, // pages in LRU cache (default: 256)
ParallelPageReadsEnabled = true, // parallel I/O (default: false)
DiagnosticsEnabled = false, // verbose logging (default: false)
ValidateOnOpen = true, // format check on open (default: true)
FileAccess = FileAccess.Read, // default
FileShare = FileShare.ReadWrite, // default: tolerate Access also having the file open
// FileShare = FileShare.Read, // tighten to read-only sharing if you don't need that
UseLockFile = true, // create .ldb/.laccdb lockfile (default: true)
LinkedSourcePathAllowlist = new[] { @"C:\TrustedLinkedDatabases" },
LinkedSourcePathValidator = (link, fullPath) => !link.IsOdbc,
};
await using var reader = await AccessReader.OpenAsync("database.mdb", options);
var writerOptions = new AccessWriterOptions("secretPassword")
{
UseLockFile = true, // create .ldb/.laccdb lockfile (default: true)
RespectExistingLockFile = true, // throw IOException if lockfile already exists (default: true)
};
await using var writer = await AccessWriter.OpenAsync("database.mdb", writerOptions);try { var dt = await reader.ReadDataTableAsync("Orders"); }
catch (FileNotFoundException) { /* file missing */ }
catch (UnauthorizedAccessException) { /* no password provided, or wrong password */ }
catch (InvalidDataException) { /* corrupt or non-JET file */ }
catch (JetLimitationException) { /* deleted-column gap, numeric overflow */ }
catch (NotSupportedException) { /* write: CLR type not mappable to a Jet column, or table definition too large for one TDEF page */ }
catch (ObjectDisposedException) { /* reader already disposed */ }All password-protected formats produced by Microsoft Access from Access 97 through Microsoft 365 are fully read- and write-supported. Supply the password via AccessReaderOptions.Password or AccessWriterOptions.Password; the format is auto-detected from the file header.
- In-place mutation. All formats (Jet3 XOR, Jet4 RC4, ACCDB legacy
;pwd=, AES-128 CFB-wrapped, and Office Crypto API "Agile") are writable in place — modified pages are re-encrypted on flush, and Agile containers are re-emitted onDisposeAsync. - Encryption mutation APIs.
AccessWriter.EncryptAsync(path, password, AccessEncryptionFormat, …),AccessWriter.DecryptAsync(path, password, …), andAccessWriter.ChangePasswordAsync(path, oldPassword, newPassword, …)add, remove, or rotate encryption (and switch formats) on an existing file. UseAccessWriter.DetectEncryptionFormatAsync(path)to discover the current format.
| Format | Versions | Detection | Key derivation | Page / payload cipher |
|---|---|---|---|---|
| Jet3 page XOR | Access 97 (.mdb) |
header byte 0x62 bit 0x01 |
static 128-byte mask | XOR (no password required) |
| Jet4 RC4 | Access 2000–2003 (.mdb) |
header byte 0x62 value 0x02 / 0x03 |
password XOR-verified at 0x42; dbKey at 0x3E |
per-page RC4 with MD5(dbKey ‖ pageNumber) |
| ACCDB legacy password | Access 2007+ (.accdb, ;pwd=...) |
header byte 0x62 value 0x07 |
password XOR-verified at 0x42 |
none (password only) |
| ACCDB AES-128 (CFB-wrapped) | Access 2007+ (.accdb) |
CFB magic D0 CF 11 E0 + Jet4-style header password |
SHA-256(password) → 16 bytes | per-page AES-128-ECB |
| ACCDB Agile (Office Crypto API) | Access 2010 SP1+, Microsoft 365 (.accdb) |
CFB compound document with EncryptionInfo (version 4.4, flag 0x40) and EncryptedPackage streams |
ECMA-376 §2.3.4.11 PBKDF: SHA-512 + spinCount iterations + spec block keys (0xfea7d2763b4b9e79, 0xd7aa0f6d3061344e, 0x146e0be7abacd0d6) |
AES-256-CBC over 4096-byte segments with per-segment IV SHA-512(keyDataSalt ‖ uint32_le(segmentIndex))[:16] |
The items below are either not yet implemented or are important behavioral caveats, and are the most likely places to hit a wall.
- Calculated columns (Access 2010+ expression columns) — read-only metadata. The library reads calc-column flags and the
Expression/ResultTypeproperties produced by Microsoft Access and surfaces them viaColumnMetadata.IsCalculated/.CalculationExpression/.CalculatedResultType. Writing calc columns (Phase 1B — emitting the extra-flags byte, theExpression/ResultTypeLvProp entries, and the 23-byte calculated-value wrapper) and client-side evaluation of expressions (Phase 2+) are not yet implemented.CreateTableAsyncthrowsNotSupportedExceptionwhenColumnDefinition.IsCalculated = true. See docs/design/calculated-columns-format-notes.md for the implementation plan.
- Do not treat a single
AccessReader/AccessWriterinstance as a parallel worker. Low-level page I/O is funneled through one internal gate, so overlapping calls on the same instance block behind each other rather than running in parallel;AccessWriteralso allows only one active explicit transaction per instance. Concurrent writers against the same file will corrupt it. Open withUseLockFile = trueandRespectExistingLockFile = true(both defaults) to fail fast when another process already holds the database. The page byte-range locks are cooperative/advisory: they help protocol-obeying writers serialize page mutations, but they are not a substitute for external coordination with arbitrary tools.
- Out of scope. The library targets the JET storage layer only.
MSysObjectsentries of type Form, Report, Macro, Module, or Query are preserved on disk but are neither parsed nor editable.
- No SQL parser, query engine, or ODBC driver. This library is a managed reader/writer over the JET on-disk format, not a database engine. Filter, project, and join through LINQ over
Rows(...)/Rows<T>(...)instead.
Based on the mdbtools format specification. The library parses JET pages directly:
- Page 0 — header: Jet3/Jet4 detection, code page, encryption flag
- Page 2 —
MSysObjectscatalog: table names → TDEF page numbers - TDEF pages — table definition chains: column descriptors + names
- Data pages — row slot arrays → null mask + fixed/variable fields
- LVAL pages — long-value chains for MEMO and OLE fields
Issues and pull requests welcome at github.com/diegoripera/JetDatabaseWriter.
MIT — see LICENSE for details.