LibRed: metadata surface, stored queries, complex columns, and byte-parity writes - #302
Merged
Merged
Conversation
ACE never forces the OS cache to disk - no FlushFileBuffers on a statement, on an explicit commit or at close - and opens the file without write-through, so its durability is the OS cache plus the commit-byte protocol. Its commit-sync settings change only whether a write is issued inside the statement or after it returns. Two orderings hold in every mode: a page's lock is released only after that page has been written, and the connection's own commit slot is written immediately before the first page of a batch and again after the last. An explicit transaction writes nothing until it commits, and growth writes the new page's last byte before the page. The transactions design note claimed a commit fsyncs twice; it now records the measured protocol, and what LibRed does against it. Also note that how aggressively freed long-value pages are reclaimed is an engine setting, RecycleLVs, which ships as 1 on ACE and 0 on Jet 4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetSchema served nothing, a reader could not describe its own columns, and a command took only text. All three now answer from the catalog and the planner, in the shapes ACE's OLE DB provider uses - verified row for row against it over Northwind: tables, columns, indexes, primary and foreign keys, the four constraint rowsets, statistics, views and procedures all match, and the two ACE serves through neither call (procedure parameters, view column usage) follow the OLE DB shapes. TABLE_TYPE comes from MSysObjects.Flags, a view's columns from planning its query - so a passed-through column reports the stored column behind it, facets and all - and a table's logical indexes carry the foreign-key direction byte, which is what lets an incoming relationship stay hidden as ACE hides it. The reader implements IDbColumnSchemaGenerator and GetSchemaTable from the same description: each output column carries the stored column it came from, so an alias keeps the stored name, a joined column names the other table, and a computed one stands alone as read-only. Key and base information is reported whether or not KeyInfo was asked for, since the round trip that flag exists to avoid is one this provider never makes. A command now accepts StoredProcedure and TableDirect, executing the named query with its arguments bound by name. An Access PARAMETERS clause ends in a semicolon, so the batch splitter no longer breaks it away from the query it declares for - which is the form every stored parameterized query reads back as. CommandBehavior is honoured rather than ignored: SchemaOnly plans a statement for its shape and runs none of it (measured: ACE leaves an INSERT's table untouched under it), SingleRow stops at the row already buffered, and CloseConnection closes the connection with the reader. Describing needs the executor to build a plan that reads nothing, so every leaf yields no rows and no seek key, offset or count is evaluated - which is also why a stored procedure can be described without the parameter values nobody supplied. Reporting a view's columns goes through the same path, where it used to sort and buffer the whole of any view with an ORDER BY. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stored action query was read back as executable SQL for only two of its kinds, and written for the same two. The rest - UPDATE, DELETE, make-table, and an append fed by a SELECT - were recognised, named and refused, although the engine could run every one of those statements already. They are recognised because Access stores them the way it stores a view: one row per source table, one per join condition, one for the WHERE, one per declared parameter. Only the action row and the meaning of the column rows differ - an assignment names its target column, qualified when the update runs over a join; an append names the column it fills; a DELETE keeps the verbatim `table.*` when the statement names one, and nothing at all when it does not. So the rebuild now shares the clause builders with the view path, and every kind reads back and runs by name. Writing them is the same arrangement in reverse, and what it writes is row for row what ACE writes for the same statement, object flags included, which is how each kind is checked. The MSysObjects flags are DAO's QueryDef.Type in the low byte - not the action row's own numbering, which differs. A declared parameter now survives the round trip. It could not before: its size never reached the type mapper, so Text(50) was stored as a memo, and its facets were dropped, so Access rendered Text(255) back. Those facets live in the parameter row's LvExtra - a length for text, precision and scale packed together for a decimal, nothing for the types that record neither, a sized binary included. Elsewhere in the row set LvExtra holds uninitialised bytes, so nothing reads or writes it there. The parser lowered PARAMETERS names in a SELECT and an INSERT but not in an UPDATE or a DELETE, which left a parameterized update comparing a column against itself. A procedure or view body with no FROM at all - `SELECT 1 AS n`, which Access stores and opens - threw NullReferenceException out of the decomposer, and would have been dropped silently on the way back in. Access stores it as any other query minus the table rows; so does LibRed now. ACE will open such a query but not use it as a source, on its own files as much as on ours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec gave text's 1..255 limit but not what happens when DDL asks for more, and the bullet next to it is about an over-long value, which is a different refusal with a different message. Measured: VARCHAR(255) creates the column, VARCHAR(256) and anything above it fails the whole CREATE TABLE with "Size of field is too long" - not promoted to a memo, not clamped. LibRed refuses the same declarations at the same threshold and opens its message with ACE's wording, which is already covered by tests on both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two-argument LTRIM/RTRIM, as SQL Server 2022 has them: the second argument is a SET of characters rather than a substring, so every leading (or trailing) character found in it is removed and the stripping stops at the first that is not. An empty set strips nothing and either argument Null gives Null. The one-argument form is untouched - still Access's, stripping the space and U+3000 alone - and Trim still takes only the one, since Access's own second argument does not exist. STRING_AGG turned out to be a second spelling of the LISTAGG already here, so it shares the implementation, DISTINCT, FILTER (WHERE …) and the window form. What differs is what each insists on: LISTAGG needs its WITHIN GROUP and lets the separator go, where STRING_AGG needs the separator and lets the order go - and without a WITHIN GROUP the values list in the order the rows arrive, which over a window is window order. The separator is written as a string, because it is the one value the whole group shares. The ACE arity test records LTrim/RTrim as taking one argument or two, the way it already records Log: ACE rejects the second, deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetDataTypeName answered with the CLR type's name, so every text column read as "String" whether it was a Char, a VarChar or a LongText - while the same reader's GetColumnSchema and GetSchemaTable had the provider's name for it all along. It now gives that name, which is also the one the DataTypes collection and Columns.TYPE_NAME use, so one type has one name across the whole surface and the fixed/variable distinction survives. ACE answers this with the OLE DB spelling instead (DBTYPE_WVARCHAR where this says VarChar), but its own schema rowsets use these short names, so matching them is what keeps the provider consistent with itself. A result with nothing described behind it - a system-variable select - has no stored column to name and still falls back to the CLR name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/Directory.Build.props turns on AnalysisMode=Recommended and EnforceCodeStyleInBuild. TreatWarningsAsErrors already applied, so every diagnostic is a build error. The test projects import the repo-root props and stay on the SDK's default rule set. Nearly everything is fixed rather than suppressed: culture arguments on locale-sensitive conversions and comparisons, Any() -> Count, First() -> indexer, sealing, GC.SuppressFinalize, ObjectDisposedException.ThrowIf, missing <param> tags, override parameter names matched to their base, dead members removed. A #pragma, each with a written reason, only where the rule's own fix would change behaviour or fights a fixed external contract: IDE0028, whose collection expression silently drops a comparer (measured); CA1859 where it is wrong about what a method returns; CA1711/CA1069 on the ADO/ADOX/DAO type-library enums, which are transcribed name-for-name; CA1051, CA1010, CA1848 and CA2254 on EF Core and ADO.NET house shapes. Three behaviour changes: eight `throw new Exception(...)` in EFCore.Jet.Data become InvalidOperationException; the .mdb extension test in both database creators is now case-insensitive, where .MDB never matched before; and JetDualTable's two public static fields become properties. src/Shared/SharedSource.props declares the shared sources and the implicit usings they need, imported by the five projects that compile them. Those projects' <Using> lists had drifted, so a directive redundant in one was load-bearing in another, and IDE0005 is evaluated per project - formatting one project would delete a using the rest still needed. RowEncoder keeps its JetFormatBase parameter, unread until Jet 3 needs it. CLAUDE.md and AGENTS.md said src/LibRed/Directory.Build.props bypasses src/Directory.Build.props. It imports it, which is how the LibRed projects pick the analyzers up; corrected, and both now describe the analysis settings and the src/Shared formatting trap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DateDiff counts into Access's Long Integer, so DateTimeOffset.ToUnixTimeSeconds overflowed it against any date before about 1902 — the BasicTypes data holds year-0101 ones — and the evaluator threw. ACE does not: measured over OLE DB against that data, the column reports adInteger for every row while those rows come back null. SQL Server's DATEDIFF has the same Int32 ceiling and documents the same limits, and answers them with a separate DATEDIFF_BIG. So DateDiff now gives Null where a count will not fit, and DateDiff_Big counts the same boundaries over the same interval table into an Int64. Hours cannot pass a Long Integer between any two dates Access represents; minutes, seconds and milliseconds can, and only those are checked. "ms" is no longer the one interval DateDiff counts wide: it returns an Int32 like the rest, and DateDiff_Big is what counts further. Extended mode translates both Unix-epoch methods through DateDiff_Big, in a copy of the Jet translator selected by the same mode ternary the Math ones use. Compatible mode keeps the Jet translator, because its statements have to run against ACE, which has no such function — so ToUnixTimeMilliseconds there is short a row rather than wrong, which is ACE's own ceiling and not ours to fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That commit gave DateDiff a different contract - a count that will not fit is Null rather than an OverflowException, and "ms" counts into a Long Integer like every other interval instead of being the one that counts wide - but left the tests that pinned the old one. Six have failed since. Values_past_their_type_overflow keeps DateSerial and TimeSerial, which still overflow. The two DateDiff cases move to a test that asserts Null, joined by "ms", which now has the same ceiling; DateDiff_Big answers the same three spans in an Int64. The Int64 expectations in DateDiffMillisecondTests move to DateDiff_Big rather than going away, so the wide count keeps its coverage: Spans_beyond_int32 asserts Null from DateDiff and the whole 50-year count from DateDiff_Big, and the declared-column-type theory names the function beside the interval. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A file of saved queries showed two ways a query Access wrote comes back wrong, both silent. A totals query with a HAVING was dropped from the catalog entirely. The reconstruction bails on any attribute outside a known list and 0x0A was not in it, so the query surfaced neither as a view nor as an action query and selecting from it said the table did not exist - a missing-object error for an object plainly in MSysObjects. GROUP BY and ORDER BY were already rendered either side of it. The engine could always run the statement; only the rendering could not write it. HAVING now reads and writes like the rest: ViewSpec and ViewDefinition carry it, ViewCreator writes the row flagless as the WHERE row is, and both the view and the make-table paths render it between GROUP BY and ORDER BY. The parser's refusal goes with it. A test pinned that gap from the other side, asserting an ACE-authored HAVING view appears as neither an action query nor a view because such queries are omitted until their attributes reconstruct losslessly. That is the limitation being lifted here, so it now asserts the view is present and that the HAVING reaches the rebuilt SQL - reading the GROUP BY while dropping the HAVING would answer with every country rather than fail. ViewSpec's summary said aggregates, GROUP BY, HAVING and ORDER BY were "not permitted" while three of the four were already members; the same stale claim sat on ViewDefinition and BuildViewDefinition. Corrected. The second is the designer's own way of writing a qualified column: one delimited name, [Order Details.UnitPrice], not [Order Details].[UnitPrice]. Nothing resolved it, so Hex([MSysObjectsEXT.Flags]) failed to bind. A period cannot appear in an Access object name, which makes the split unambiguous, and it happens in the parser rather than at lookup because the planner reads ColumnReference.Table to decide which source a predicate belongs to - resolved later, the reference would find its value but stop being pushed down or seekable. An undelimited a.b never reaches that path; the grammar has already split it. Also recorded: MSysDb's ANSI Query Mode, the per-database half of Access's "SQL Server Compatible Syntax (ANSI 92)" option. It is a real field - 33 of 38 files carry 0, five carry 1 - and the engine does not consult it. Measured across nine combinations: absent/0/1 against OLE DB, ODBC, and ODBC with ExtendedAnsiSQL=1, for ad-hoc SQL and for a saved query, authored by ACE itself or by LibRed. Every result identical. The wildcard set belongs to the connection instead: OLE DB is ANSI-92, plain ODBC reads a saved query's pattern as ANSI-89, and ODBC with ExtendedAnsiSQL=1 - which JetConnection sets on every ODBC connection - is ANSI-92 again. Worth the words because the corpus argues the opposite: all 17 extractable LIKE patterns in saved queries are ANSI-89, which makes it look as though the file's mode must be driving them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A complex column - the type behind multi-value and attachment fields - decoded as a long-value descriptor and gave back nothing usable. Its in-row value is not a descriptor at all but a 4-byte record id, and the values themselves are rows of a per-column flat table keyed by it. Core now resolves the pair and reads them: JetCatalog walks MSysComplexColumns to the flat table and finds the owner link and the per-value id by index shape rather than by building names, because neither name follows a rename - a column now called BK_category can be backed by f_..._TempField*7. JetTypeCodec decodes the in-row value as the Int32 it is, and IndexKeyEncoder keys it as one, verified across 43 entries in 9 such indexes. The id is allocated per row, not per value, and every complex column of a table shares it, because the counter is a single TDEF word at 0x1C rather than one per column. So a row carries the same id in all four of complex1's attachment columns, an empty read is the ordinary answer rather than an error, and RowInserter advances 0x1C separately from the table's own counter at 0x14. Attachments write too: ComplexAttachment packs the payload Access expects - an outer header carrying a compression flag and the uncompressed length, then an inner header with the extension as null-terminated UTF-16 - and ACE extracts a file LibRed attached, byte for byte. Whether a file is deflated is Access's convention keyed on extension, not a rule of the format; both forms read back, and the 256 MB ceiling is Access's policy, well under the format's own. That a complex column carries the descriptor's 0x04 AutoNumber flag had a consequence nothing accounted for. Five columns of complex1's Table1 read as AutoNumber, and MSysResources in every Northwind has two - so "a table has at most one AutoNumber column" is false as stated, and three guards enforcing it rejected tables Access created normally: any ALTER COLUMN on such a table threw. The rule is about the header's seed/increment pair, which one column may draw on; a complex column is flagged the same but allocates from 0x1C. Worse, the read applied that pair to every flagged column, so all five reported the ID counter's seed as their own. Fixed at the read and in all three guards. Then the writes themselves. A delete now leaves the bytes ACE's delete leaves, measured by running the same delete through DAO and through LibRed on two copies and diffing whole files - an ordinary table and one whose delete cascades into four flat tables, 5 and 20 pages, every page identical and no page touched that ACE left alone. Three things differed, all of them dead-byte handling. A page is zero-filled when allocated and never cleared again, so an in-place rewrite must carry the destination's bytes past the new live end instead of building in a clean buffer. A delete keeps the prefix length the page was stored at, because ACE re-compresses a leaf only when it fills and a delete never fills one. And the per-index entry counters skip a block whose total already reads 0 - not a clamp, since unique stays where it is rather than dropping - which is what kept LibRed's totals from going negative. That last one also reconciles an apparent contradiction in the spec: an index built empty and filled by inserts has total 0, so the delete skips it, which is why unique looked as though it never fell. A leaf splits where ACE splits it. The cut is taken over the entries the page held before the insert, and the new entry joins whichever side its key falls on, so the original page keeps one more entry for a key landing below the midpoint than for one landing on or above it; halving the post-insert list instead always hands the odd entry rightward. Measured on a 602-entry leaf split by a single further key at positions 1, 50, 301, 302 and 400 - the isolated case, because the cascade that first showed the difference ran three levels deep over three indexes and could attribute nothing. Left alone deliberately: page allocation. Both engines pick the same first free page, then ACE extends the file by an eight-page chunk while LibRed reuses another free page - verified genuinely free, with every table's rows intact and ACE reading the result back. Matching would mean growing files that need not grow. One split case is still open rather than solved: a key sorting below everything on the page splits at the same point, but ACE then stores that page uncompressed, and the two explanations that fit are not yet separable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A complex column reported itself to INFORMATION_SCHEMA and the scaffolder as a non-nullable "varchar", which is wrong twice and malformed once. ACE has no way to name such a column over OLE DB, so it flattens it to long text: an attachment column gives the identical DBSCHEMA_COLUMNS row to a Memo one - type 130, flags 234, maximum length 0 - and a reader types it System.String at the Memo ceiling of 536,870,910. Only DAO names it at all, as type 101, dbAttachment, size 4, that size being the Int32 complex id. JetStoreType had no Complex case, so the name fell through the default to "varchar" - which is also the one store type in the map carrying neither a facet nor a reason to lack one, where every other text type is varchar(n) or char(n). It now reads longchar, the same as Memo, so a caller sees what it would see through ACE. The nullability was the auto-number flag deciding on its own again. A complex column carries 0x04 without drawing on the table's counter, so the rule that an AutoNumber column is never nullable does not reach it - and both ACE surfaces disagreed with us, OLE DB reporting IS_NULLABLE true and DAO Required false. Only the flag is ignored; nullability still comes from the column's own IsNullable, which the catalog sets from the LvProp Required property, and those columns carry no LvProp entry at all. Both derivations live in JetStoreType precisely so INFORMATION_SCHEMA and database-first scaffolding cannot drift apart, so both surfaces move together. The CLR type is left as Int32 rather than ACE's String: a SELECT of a complex column still yields the record id, and that changes when the values projection does, not here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every Linux and macOS leg fails the build with IDE0055 on lines nobody has
touched, while Windows builds clean, because two settings that have to agree
do not.
.gitattributes normalises with `* text=auto`, so the index holds LF and the
working tree takes whatever the platform prefers - CRLF on Windows, LF
everywhere else. .editorconfig pins `end_of_line = crlf`, for [*.cs] and again
for [*.{cs,vb}]. On Windows the checkout matches what the rule asks for; on
Linux every line ending violates it. That was a suggestion until
EnforceCodeStyleInBuild arrived alongside TreatWarningsAsErrors, which turned
it into a build error, so the platform the analysers never ran on is the one
that stopped compiling.
Pinning the checkout is the smaller of the two fixes: the index is untouched -
it stores LF either way - no file is renormalised, and the style contract the
repo already states twice is left as it is. Setting end_of_line to lf instead
would have moved the rule to match the accident of how each platform checks
out, and broken Windows in the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull_request.yml had the two halves of the file-format suite the wrong way round, and lost one of them entirely. LibRed.Core.Tests ran on LibRedAccess, the Windows-with-ACE job. It needs no driver at all, and running it there asserts nothing the five-platform LibRed job could not assert better - reading and writing the on-disk format on Linux, macOS and ARM64 is precisely the part of the cross-platform claim that was going unchecked, because that job ran only LibRed.Engine.Tests. It now runs both, as push.yml already does. LibRed.Core.AccessTests was not run by this workflow at all. It is the half that cross-checks LibRed's output against the real engine, so it belongs on the Windows job, in the slot Core.Tests was occupying. Its path was also missing from the libred filter, so a change confined to that suite marked the whole LibRed leg as unaffected and skipped it. push.yml already had all three of these right; this brings the pull-request flow level with it, comments included. pull_request_without_ci.yml runs no suites and needs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch gives LibRed an ADO.NET metadata surface, reads and writes every kind of stored query Access stores, adds complex (multi-value and attachment) columns, and brings its index writes to byte parity with ACE. It also turns the analysers on across
src. Nearly every behaviour change was measured against ACE first, and the file-format changes are pinned by whole-file byte comparisons against ACE's own output.LibRed ADO.NET surface
GetSchemaTable/IDbColumnSchemaGenerator: each output column carries the stored column it came from, so an alias keeps the stored name, a joined column names the other table, and a computed one is read-only. Key and base information is reported whether or notKeyInfowas asked for.GetDataTypeNamegives the provider's name, not the CLR type's. Before, every text column read asString.CommandType.StoredProcedureandTableDirectexecute the named query with arguments bound by name. The batch splitter no longer breaks an AccessPARAMETERSclause away from the query it declares for.CommandBehavioris honoured:SchemaOnlyplans a statement and runs none of it,SingleRowstops at the buffered row,CloseConnectioncloses with the reader.ORDER BY.LibRed SQL engine
LvExtra.Text(50)was stored as a memo and rendered back asText(255).PARAMETERSnames are lowered in an UPDATE and a DELETE too, so a parameterized update no longer compares a column against itself.FROMno longer throws out of the decomposer.HAVINGreads and writes. A totals query carrying one was dropped from the catalog entirely, and selecting from it said the table did not exist.[Order Details.UnitPrice], binds. A period cannot appear in an Access object name, so the parser splits it.DateDiffgives Null where a count will not fit, as ACE does, instead of throwing;DateDiff_Bigcounts the same boundaries into an Int64."ms"is no longer the one interval that counts wide.DateDiff_Big; compatible mode keeps the Jet translator.LTRIM/RTRIM, as SQL Server 2022 has them: the second argument is a set of characters.Trimstill takes one, since Access's second argument does not exist.STRING_AGGis a second spelling ofLISTAGG, sharing DISTINCT,FILTER (WHERE …)and the window form.LibRed file format — complex columns
0x1C, separate from the table's own. An empty read is the ordinary answer, not an error.0x04AutoNumber flag, so a table can have several columns that read as AutoNumber. Three guards enforcing "at most one" threw on anyALTER COLUMNof such a table.varchar.LibRed file format — writes that now match ACE
0alone, unique included. Without this the totals went negative.Build and analysers
src/Directory.Build.propsturns onAnalysisMode=RecommendedandEnforceCodeStyleInBuild, so every CA/IDE diagnostic insrcis a build error. The test projects stay on the SDK default.#pragmawith a written reason only where the rule's own fix would change behaviour:IDE0028, whose collection expression drops a comparer;CA1711/CA1069on the transcribed ADO/ADOX/DAO enums;CA1051,CA1010,CA1848,CA2254on EF Core and ADO.NET shapes.throw new Exception(...)inEFCore.Jet.DatabecomeInvalidOperationException; the.mdbextension test in both database creators is case-insensitive, where.MDBnever matched;JetDualTable's public static fields become properties.src/Shared/SharedSource.propsdeclares the shared sources and the implicit usings they need. The five projects'<Using>lists had drifted, andIDE0005is evaluated per project, so formatting one would delete a using another still needs.Tests and CI
* text=autogave the working tree the platform's ending while.editorconfigpinscrlf, so every Linux and macOS leg failed the build onIDE0055once it became an error. The index is unaffected.pull_request.ymlthe driver-freeLibRed.Core.Testsran on the Windows ACE job, the five-platform job ran no Core tests, andLibRed.Core.AccessTestsran nowhere and was missing from thelibredpath filter.push.ymlalready had this right.updateStatement/deleteStatementas procedure bodies; the parser is regenerated fromAccessSql.g4with the committed script.Docs
src/LibRed/docs/format): the index B-tree, TDEF and column pages, the system catalog and the complex-column layout are updated with everything above, including the two parity questions left open.VARCHAR(256)and above fails the wholeCREATE TABLE, rather than being promoted to a memo or clamped. LibRed refuses at the same threshold with ACE's wording.CLAUDE.md/AGENTS.md: the analysis settings and thesrc/Sharedformatting trap are described, and the claim thatsrc/LibRed/Directory.Build.propsbypassessrc/Directory.Build.propsis corrected — it imports it.🤖 Generated with Claude Code