Version 2026.10.17
Breaking Changes
- csdb removes the
psqlload and the argumentspg_loadandpg_widen_doubles. Every PostgreSQL insert, upsert andinsert_data_many()sends its rows as JSON over the open connection.create_table()always widens arealcolumn declared DOUBLE todouble precisionin place.
Version 2026.10.16
New Features
- New argument
pg_loadofDBConnection_v9$new(), whichDBTable_v9$new()also reads fromdbconfig. Withpg_load = "json", a PostgreSQL insert, upsert orinsert_data_many()sends its rows as JSON text over the connection that csdb holds. It starts nopsql, so it makes no new login. The default stays"psql".
Bug Fixes
- The
psqlload writes PostgreSQL’s text format, with each backslash, tab and line end escaped. A text value with a double quote no longer stops the load, and an empty string is no longer stored as NULL. - The
psqlload writes each double as text that PostgreSQL reads back as the same double: the shortest such text, except a whole number below 2^63, which is written in full digits so it also loads into an integer column. With 15 significant digits,0.1 + 0.2arrived as0.3. - A load into PostgreSQL, SQL Server, MySQL or SQLite accepts a column named
i. Until now it stopped with “invalid first argument”. - On PostgreSQL, the field type DOUBLE creates
double precision. Until now it createdreal, which keeps about 7 significant digits. - An existing
realcolumn declared DOUBLE staysrealuntil you widen it on purpose. csdb counts it as matching, so it does not drop the table, and it gives one message per table in each session. - To widen, set
pg_widen_doubles = TRUEin thedbconfigofDBTable_v9$new()or inDBConnection_v9$new(). The nextcreate_table(), whichconnect()calls, then changes each such column todouble precisionwith oneALTER TABLE. The table keeps its rows, and PostgreSQL rewrites it and blocks it during the rewrite. TheALTERwaits at most 60 s for its lock, then stops with an error and leaves the columnsreal. - A value stored as
realkeeps its float4 precision after the widening. Import it again for the full precision.
Version 2026.10.15
New Features
- New
csdb_set_context()andcsdb_get_context(). With a non-empty context, each PostgreSQL connection ofDBConnection_v9and eachpsqlload runSET application_name, sopg_stat_activitynames the caller. The name keeps only the charactersA-Z a-z 0-9 _ | . : -of the context, at most 63. After a failed SET, a connection that answersSELECT 1stays open, and one that does not counts as a failed connect attempt. A failed SET stops apsqlload.
Version 2026.10.14
Version 2026.10.13
New Features
- New
insert_data_many()inserts one data.table into each table of a list. On PostgreSQL onepsqlsession loads every table in one transaction. So the load needs onepsqllogin, and by default it stores every row or no row. The one exception is a duplicate key withconfirm_insert_via_nrow = TRUE: csdb then inserts table by table. With another driver, or with tables that have different connection settings, it calls$insert_data()on each table in turn. - The session skips a table with no rows, and checks the row count of each table before the commit. After a load exits without an error, a row count that differs stops with class
csdb_load_ambiguous, and the error says to count the rows. It waits at most 60 s for one lock, and it runs again after a lock timeout, a deadlock or a refused login.
Version 2026.10.12
Bug Fixes
- A
psqlload retries a login that an Entra token proxy refuses with “internal proxy error”. The waits before attempts 2 and 3 are now 2 s and 10 s, not 1 s and 3 s. -
DBConnection_v9$connect()makes 3 attempts by default, not 2. Like apsqlload, it waits 2 s before attempt 2 and 10 s before attempt 3. Until now it waited 1 s. - The message of each failed
psqlorbcpattempt holds the output of the client, also for a failure that csdb does not retry.
Version 2026.10.8
New Features
- New
csdb_set_password_hook()andcsdb_get_password_hook(). The hook gives the password for each new PostgreSQL connection and eachpsqlload, so csdb fetches an expiring token again each time. Other drivers keep their configured password.
Version 2026.10.7
Bug Fixes
- The default MySQL methods of
load_data_infile()andupsert_load_data_infile()write their load file totempfile(fileext = ".csv"), a new file intempdir()for each call. The old fixed path/tmp/x123.csvlet two concurrent upserts write the same file. When a write fails partway, the PostgreSQL, SQL Server and MySQL methods now delete their default load file.
Version 2026.10.6
Bug Fixes
-
DBTable_v9creates each declared index that is missing on an existing table, once per object, on the first call that reaches the table. Until now only a table that csdb created got its indexes. - A failed create on that path gives a warning that names the table and the index, and the call continues. An index under an old name, such as a bare
ind1, stays beside the new one.
Version 2026.10.5
New Features
- The lint gate in
.github/workflows/check-and-pkgdown.ymlpasses. Its allowlist exempts every function in 3 files fromcyclocomp_linter:R/r6_DBConnection_v9.R,R/r6_DBTable_v9.RandR/util_database.R. These hold the wholeDBConnection_v9andDBTable_v9classes andget_db_classes().
Bug Fixes
- csdb shell-quotes every argument that it gives
psqlorbcp. A\copycommand keeps its quoted identifiers, and a password with shell metacharacters cannot break or inject a command. - The
psql\copycommand quotes the column names and the file path. -
psqlreads the password fromPGPASSWORD, not from the connection URI. csdb URL-encodes the user, the host and the database in the URI. -
bcpreceives each of-aand-hand its value as two separate arguments. -
bcpstill receives the password with-P. Usetrusted_connection = "yes", which givesbcpthe-Tflag, to keep the password off the command line. - The message of a failed load drops the
bcpprogress lines and holds at most 82 lines. It keeps the first 20 and the last 20 distinctError =andSQLState =lines. It also keeps the first 20 and the last 20 other lines. - The message of an ambiguous load warns: a table without a primary key can get duplicate rows if you run the load again by hand.
- The PostgreSQL and default statements of
add_constraint(),drop_constraint(),drop_rows_where(),keep_rows_where(),drop_table()anddrop_all_rows()quote the table and constraint names. The SQL Server statements are unchanged. Fixes #5. - PostgreSQL
keep_rows_where()works again. In 2026.10.4 it stopped with “argument is of length zero” on every table. It now runs the copy, the drop and the rename in one transaction. - csdb puts the ODBC password in braces, so a password that holds
;,{,}or=connects. A user name that holds one of these characters is still not supported, because psqlODBC keeps the braces in the user name. -
utilsis in Imports, because csdb callsutils::URLencode()andutils::packageDescription(). - Still open: dotted text table identities, the primary key column list in
add_constraint(), and the old constraint names of existing tables.
Version 2026.10.4
New Features
-
DBTable_v9$replace_all_rows(newdata)replaces every row in one transaction, or leaves the table unchanged when a statement fails. The rows go through DBI, so use it for small tables.
Bug Fixes
- csdb now names a primary key constraint
pk_<slug>_<16 hexadecimal characters>, at most 63 characters, by the rule that names an index. The old name wasPK_plus the table name with.,[and]deleted. It could give two tables one name, and PostgreSQL truncated it past 63 characters. A table that an earlier release created keeps its old name. -
insert_data(),upsert_data()and the twodrop_all_rows_and_then_*()methods takeload_timeout, in seconds. csdb kills apsqlorbcpcall that runs longer, and stops with classcsdb_load_ambiguous. - The default is 3600 s, because Airflow kills a norsyss task after 60 minutes (
DEFAULT_TIMEOUT_MINUTESinFhi.Norsyss/dags/norsyss.py). - csdb does not retry the same load after a timeout. An Airflow retry runs the whole task again. That is safe for a task that rebuilds its output from the start.
-
bcpgets a login timeout of 30 s (-l 30), andpsqlgetsconnect_timeout=30. - The file that csdb hands to
psqlorbcpnever holds a number in scientific notation. csdb wrote 100000 as1e+05, and PostgreSQL refused it for an integer column.
Version 2026.10.3
Bug Fixes
Loads into PostgreSQL and SQL Server now stop on failure. csdb ignored the exit status of
psqlandbcp, so a load that the server refused lost its rows and raised no error. Fixes #6.csdb sorts a failed load into one of four classes, from the output of the client: transient, ambiguous, duplicate key, or other. It runs no query to do this, and it reads the output before it replaces the password.
A transient failure certainly stored nothing, so csdb runs the same load again. That is at most 3 attempts, with a wait of 1 s and then 3 s.
An ambiguous failure broke the connection after the load started. The server may or may not have committed the rows, so csdb stops at once.
The error then has class
csdb_load_ambiguous, and it says to count the rows in the table before the next load.-
The phase decides the class.
psqlstarts every error that it meets while it connects withpsql: error:.bcpprintsStarting copy...when it starts to send rows.Failure Phase Class Refused, timed-out or reset connection, DNS failure, too many clients, server starting or stopping connect transient psqlSQLSTATE 40001, 40P01, 53300, 55P03 or 57P03transfer transient psqlSQLSTATE 08xxx, 57P01 or 57P02, or a lost connectiontransfer ambiguous bcpSQLState 08xxx, HYT00, HYT01 or S1T00connect transient bcpSQLState 08xxx, HYT00, HYT01 or S1T00, or NativeError 596transfer ambiguous bcpSQLState 40001, or NativeError 1205 or 1222any transient psqlruns with-v VERBOSITY=verbose, so it prints the SQLSTATE. A failed connection prints no SQLSTATE, so csdb reads the libpq text. For too many clients that is “sorry, too many clients already” or “remaining connection slots are reserved”.A duplicate key stops at once. That is SQLSTATE 23505 for
psqland NativeError 2627 or 2601 forbcp. Every other failure also stops at once.The error has class
csdb_load_transient,csdb_load_ambiguous,csdb_load_duplicate_keyorcsdb_load_other, and alsocsdb_load_error. It names the table, the client, the exit status, the class and the number of attempts.The error holds the output of the client, with the password replaced by
***.insert_data(confirm_insert_via_nrow = TRUE)catches a duplicate key and upserts the same rows once. It stops when the table then holds fewer rows than the new data.Each failed attempt and each upsert fallback emits a
message(). It names the table, the client, the attempt and the class. A load that succeeds on a later attempt emits one moremessage().These are messages and not warnings. A warning becomes an error under
options(warn = 2), and that would fail a load that succeeded.A load also stops, as class other, when
psqlorbcpreports a row count that differs from the rows csdb sent. It stops whenbcpprints anError =line and exits with status 0.The SQL Server loader no longer passes
nultobcp format. On Linuxbcpcreated a file./nul. Where R could not write the working directory, every SQL Server load stored 0 rows and raised no error.
Development
-
tests/testthat/test-load-tools.Rputs a fakepsqland a fakebcpfirst onPATH, and those tests skip on Windows. - A retry is safe because a failed load stores no row. Verified on 2026-10-03: 50,000 rows with a duplicate key at row 40,000 stored 0 rows on PostgreSQL 16 and on SQL Server 2022.
- csdb passes no
-btobcp, sobcpsends every row in one batch. A-bwould commit each batch, and a retry could then store a row twice. - Verified on 2026-10-03 for 3,000,000-row loads.
pg_terminate_backend()mid-COPY, a TCP cut mid-COPY andKILLmid-bcpeach stopped after 1 attempt as ambiguous. In all three runs the table kept 0 of the rows. -
bcp17.11 did not always return after its connection broke. This happened after a TCP cut, and after one of twoKILLruns.bcpprinted nothing afterStarting copy...and waited 9 to 11 minutes, until it was stopped by hand. csdb sets no timeout on the client, so such a load waits indefinitely. -
psqlandbcpprint a row count with no digit grouping, such asCOPY 1000000and1000000 rows copied., measured for 1,000,000 rows. -
bcpwaits for its login timeout of about 15 s on a refused connection. Three failed attempts on SQL Server therefore took 49.4 s, and 4.35 s on PostgreSQL. - A load that succeeds at once sends the same statements as before. Measured on PostgreSQL 16: 4 statements for each
insert_data(), with identical texts.
Version 2026.8.21
- The package drops
magrittr. Every%>%is now the base pipe|>, andmagrittris gone fromDESCRIPTION. - The rewrite is a relocation, not an edit. Each
%>%call was transformed the way R’s parser transforms|>, and the resulting tree was required to match the tree parsed from the rewritten file. A file whose trees disagreed was left untouched and converted by hand instead.
Version 2026.8.20
Development
-
R/util_database.Rheld 1445 code lines, and the shared CI workflow fails anyR/*.Rfile over 1000. The S7 method assignments now live in four sibling files, one per group of generics:util_database_load.R,util_database_table.R,util_database_index.Randutil_database_rows.R. - The split moved whole top-level expressions and changed none of them. R sources
R/in C collation order, and each new name sorts afterutil_database.R. Every generic and every class therefore exists before the method assignments run.
Version 2026.8.16
Bug Fixes
-
add_index()raises when it cannot create the index. Thedb_defaultanddb_postgresmethods wrapped the work intry(..., TRUE)and returned atry-errorobject. No caller reads that object, so a failed index looked exactly like a created one. - The PostgreSQL upsert asked for an index named
"ind" + random_uuid().+does not join strings in R, so the expression raisesnon-numeric argument to binary operator. R evaluated it inside theglue()inside thetry(), so every PostgreSQL upsert built its temporary table with no index. The name now comes frompaste0(). - The same call passed the temporary table as a
DBI::Id, andglue::glue()cannot coerce one.add_index()for PostgreSQL quotes the table itself now, so the call passestemp_name, theDBI::Id. Verified against PostgreSQL 16.14: the call creates an index on a temporary table that aDBI::Idnames. - The PostgreSQL index DDL quotes every identifier.
add_index()pasted the table and the key columns intoCREATE INDEXin raw, anddrop_index()pasted the schema and the index name intoDROP INDEX. A name that holds a dot, a space or an upper case letter then produced SQL that PostgreSQL rejected, or that named a different object.anon.MyTabfolded toanon.mytab, andanon.my tabwas a syntax error.add_index()for PostgreSQL takes the table as aDBI::Idnow, soDBTable_v9$add_indexes()passes theDBI::Idtoo. Verified against PostgreSQL 16.14 onnorsyss_data1, schemaanon, for three table names:zz_quote_probe.dot,zz_quote_probeUPPERandzz_quote_probe space. Each declared one index over the columnsaandC Odd.x.add_indexes()created it,pg_indexesheld it on that table with those two columns in that order, anddrop_indexes()removed it. The probe left 0 tables and 0 indexes behind. -
DBTable_v9$add_indexes()creates each declared index exactly once. The method reachescreate_table()throughlazy_creation_of_table(), andcreate_table()callsadd_indexes()again. Two declared indexes produced four attempts.CREATE INDEX IF NOT EXISTSand the swallowed error hid the duplicate. - Each declared index now reaches the database under a name built from the table identity and the logical name. csdb used the caller’s logical name in the database, verbatim. A PostgreSQL index name is unique per SCHEMA, so every table in one schema that declared
ind1asked for one name.CREATE INDEX IF NOT EXISTSanswers a taken name with a notice and not an error. The first table won the name, and every later table silently got nothing. Measured on thenorsyss_data1database on 2026-08-15: the tableanon_norsyss_datahad 87 partitions in schemaanon, and all 87 declaredind1andind2. One partition heldind2, and none heldind1. -
add_indexes()reads the catalogue after each create. A statement that returns without an error proves nothing about which table holds the name, and nothing about which columns the index covers. The method raises when the index is not on this table, and when it covers columns other than the declared ones. That check is defined for SQLite and for PostgreSQL only. See the Development note below for what any other backend gets. -
drop_index()for PostgreSQL names the schema. It emittedDROP INDEX IF EXISTS {index}, which PostgreSQL resolves throughsearch_path. csdb creates every index on a fully specified table, so the index lands in that table’s schema. The drop found it only when that schema was on the path. Thetry()around the call hid the miss. The method took atableargument and ignored it; it reads the schema from it now. -
DBTable_v9$drop_indexes(), and the catalogue check inside$add_indexes(), read the table from theDBI::Idfield and no longer from the text field beside it. Text splits on every dot, so a table calledan.on.tabread as the three componentsan,onandtab.$add_indexes()created the index and then reported that the index was on no table at all.drop_index()for PostgreSQL read the schema ason. Measured on thenorsyss_data1database on 2026-08-15: 0 tables and 0 schemas hold a dot, so this could not fire there. -
confirm_indexes()no longer drops every index to reconcile. It compared the names in the database againstnames(self$indexes)withidentical(), and dropped and re-added everything on any mismatch. Those two lists differ by design now, so that comparison could never match again. The method takes one of four actions per declared index. It does nothing when the index is present and correct. It adds the index when the index is absent. It raises when the managed name covers other columns. It ignores any index that csdb did not name. - The default upsert method maps the logical names in
drop_indexesto the names the database holds. It builds its temporary table withCREATE TEMPORARY TABLE ... LIKE, which copies the source table’s index names, and then asked to drop the logical name. A rename that reaches creation but not dropping leaves an index that nobody can remove. -
DBTable_v9$drop_all_rows_and_then_upsert_data()andDBTable_v9$drop_all_rows_and_then_insert_data()reject four kinds ofnewdatabefore they drop a row. They are aNULL, an object that is not adata.frame, a row count that is unusable or unstable, and data thatvalidator_field_contentsrefuses. Both methods dropped every row and then called the write method. The validator runs inside that write method. Invalid data therefore emptied the table before anything rejected it. Measured on 2026-08-15: a table holding 3 rows raisedupsert_load_data_infile not validatedand held 0 rows after. - Both methods raise on a
NULL, and on anything that is not adata.frame.upsert_data()andinsert_data()return early on aNULLand on a zero-row frame. Both return before they reach the validator. ANULLis therefore not invalid data: the validator never sees it. Measured on 2026-08-15: 2 rows before the call, 0 rows after it, and no error at all. A validator that runs earlier does not close that gap, so the check sits in the two destructive methods. - A zero-row
data.framethat fails the validator raises, and the table keeps every row. Before this release the row count check returned first, so nothing validated that frame and nothing raised. - A zero-row
data.framethat passes the validator still empties the table, and raises nothing.cs9::DBPartitionedTableExtended_v9clears every partition this way. - Both methods read the row count before the drop, and reuse that value afterwards.
nrow()readsdim(), and adata.framesubclass can carry adim()method that returnsNA,Inf, or a different answer on each call.is.data.frame()is TRUE on such an object, and a permissive validator accepts it. The guard readsnrow()twice. It rejects a count that is not one finite non-negative whole number, and it rejects two reads that disagree. A row count read after the drop raised onif (n == 0)with the table already empty.
Development
tests/testthat/test-index-integrity.Rcovers the fixes above. The csdb suite held 295 passes after that work, up from 206.tests/testthat/test-destructive-order.Rcovers nine kinds ofnewdata, for both destructive methods. That work took the csdb suite to 427 passes, up from 295.The csdb suite holds 464 passes at release, with 0 failures, 0 errors and 0 skips. The 37 after 427 cover the identifier quoting and the vignette. Measured on 2026-08-15 by
testthat::test_local()underpkgload::load_all().The cs9 suite holds 368 passes at release, up from 354. This csdb release does not change that number.
cs9 26.8.17addstests/testthat/test-fork-ordering.R, and that is what moves it.This release does NOT wrap the drop and the write in one transaction. A write that fails after the truncation still leaves the table empty. The guard removes the reasons csdb itself can see before the drop.
A
data.framesubclass whosedim()answers differently on a later call can still empty the table and then raise.upsert_data()andinsert_data()readnewdataagain after the drop. Closing that needs a copy ofnewdataor a transaction, and this release does neither. The two reads in the guard detect a count that changes between them, and they do not prove that a later read agrees.validator_field_contentsruns TWICE on a destructive write that carries rows: once in the guard before the truncation, and once insideupsert_data()orinsert_data()after it. Measured on 2026-08-15: 2 calls for a frame with rows, against 1 call for a plaininsert_data(). A zero-row frame still costs 1 call, because the guard returns before the write method runs. A stateful or nondeterministic validator can therefore pass the first call and fail the second, which leaves the table empty. Measured on 2026-08-15: a validator that answered TRUE then FALSE took a table from 2 rows to 0. An expensive validator pays for both calls.The check accepts only
TRUEfromvalidator_field_contents, throughisTRUE().upsert_data()andinsert_data()keep theirif (!validated)test, which also acceptsTRUEcarrying attributes. The destructive path is therefore the stricter of the two, and it is strict before the drop rather than after it.The
db_mssqlmethod keeps itstry(..., TRUE). Its SQL readsCREATE INDEX {index} IF NOT EXISTS ON {table}, which SQL Server does not accept. Its only caller passes no index name. Removing the wrapper there needs a SQL Server to verify against, so that work waits.The name in the database has three parts. They are the prefix
ix_, a readable slug, and a digest. The digest is 16 hexadecimal characters of a version 5 UUID over the table identity and the logical name. The slug carries no character outside[a-z0-9_]. csdb cuts the slug from the left when the whole name would pass 63 characters, the PostgreSQL identifier limit. PostgreSQL truncates a longer name and reports nothing, and a truncated name plusIF NOT EXISTSis the same silent no-op again. Callcsdb:::index_physical_name()to read the name for one table and one logical name.The name is collision-resistant, and it is not injective. The digest holds 64 bits, and the version nibble of a version 5 UUID is fixed, so 60 bits vary. Two different tables give one name when those 60 bits agree. The key construction removes every STRUCTURAL collision. It length-prefixes the component count, then every component of the table name, then the logical name. No two different inputs therefore build one key. The
PK_{table}rule inadd_constraint()has structural collisions, because it deletes.,[and]. Schemaawith tablebcand schemaabwith tablecboth givePK_abc.The table identity is the ordered name components, and never a joined string.
DBI::Id(schema = "a", table = "b.c")andDBI::Id(schema = "a.b", table = "c")both join toa.b.c. A joined identity therefore gives two different tables one index name. csdb passes theDBI::Idat every site that reads a table as an identity, so every site computes one name for every table name. A schema or a table name that holds a dot is covered: a test creates, verifies and drops an index on a table calledan.on.tab. Text is still accepted for a caller that holds only the text form."anon.tab"still names the same index asDBI::Id(schema = "anon", table = "tab").Text names exactly the identity of its dot-separated pieces, and that is a definition rather than a guess. No mapper rule can refuse an ambiguous text, because every dotted text is ambiguous:
"anon.tab"is equally the text form ofDBI::Id(table = "anon.tab"). A caller that holds aDBI::IdMUST pass theDBI::Id, not its joined text.The name is lowercase because PostgreSQL folds an unquoted identifier to lowercase and SQLite does not. A lowercase name therefore reads the same in the source and in both catalogues. Measured on
norsyss_data1on 2026-08-15: 92 lowercasepk_constraint names and 0 uppercase, while the source writesPK_.get_index_columns()is a new internal generic. It returns the columns of one index in index order. It returnscharacter(0)when no index of that name is on that table, andNULLwhen the backend has no catalogue reader. The SQLite method is under test. The PostgreSQL method was verified by hand against thenorsyss_data1server on 2026-08-15. Two tables in schemaanon, both declaringind1, each returnedisoyearweekfor its own index and nothing for the other. No automated test covers that method, because the csdb suite runs on SQLite alone.Column verification is defined for SQLite and for PostgreSQL, and for no other backend. SQL Server and MySQL dispatch to the
db_defaultmethod, which returnsNULL. On those two backendsadd_indexes()creates each index and does NOT verify it.confirm_indexes()there checks the name alone, so it cannot see a change of columns. Raising there instead would break every index creation on a backend that no test in this package covers.The quoting fix covers index DDL, and it does NOT cover table creation.
add_constraint()pastes the table and the key columns into itsALTER TABLEstatement in raw. Thedb_defaultand thedb_postgresmethods both do this, and both readtable_name_short_for_mssql_fully_specified_for_postgres_text.create_table()callsadd_constraint()after it creates the table. A PostgreSQL table whose schema or name holds a dot, a space or an upper case letter is therefore created, and then the call raises. The table stays in place with no primary key. Measured onnorsyss_data1on 2026-08-15, forzz_quote_probe.dot,zz_quote_probeUPPERandzz_quote_probe space. Each of the three tables existed afterwards, and each raised insideALTER TABLE, not insideCREATE TABLE. A table calledzz_quote_probe_plainsucceeded, so the three failures are the name shapes and not the probe. The live verification of the index fix therefore created its three probe tables by hand, with quoted SQL.create_table()then skipped its own block, because the table existed and the fields matched.add_indexes()therefore ran exactly as it runs in production.drop_rows_where(),keep_rows_where()anddrop_table()for PostgreSQL interpolate the table withglue::glue(), and carry the same defect for the same three name shapes.This release does NOT repair a table that already exists.
add_indexes()runs fromcreate_table(), once, at creation. The 87anon_norsyss_datapartitions keep their missing indexes until a separate migration runs. Callingconfirm_indexes()on such a table adds the missing index.-
Two indexes on
norsyss_data1keep their legacy names, and csdb no longer manages either one. Drop them by hand:DROP INDEX IF EXISTS anon.ind1; -- on anon_norsyss_providers DROP INDEX IF EXISTS anon.ind2; -- on anon_norsyss_data_xxpxx_h77Do not run these two statements yet. Run them only after the managed replacement index exists on that table, and after
confirm_indexes()verified it there. This release does NOT repair a table that already exists, so an early drop leaves both tables with no index at all. Importscarriesuuid (>= 1.1-0).index_physical_name()callsuuid::UUIDfromName(), which arrived in uuid 1.1-0. Under an older uuid every index name raisedcould not find function "UUIDfromName".add_indexes()does not retry an index that failed after the table itself was created.create_table()creates the table first, then adds the indexes. A failure in the index step leaves a table whose fields already match. A later call tolazy_creation_of_table()therefore skips the creation block, setslazy_created_table, and never adds the missing index. Calladd_indexes()orconfirm_indexes()again to add it. This release does not change that behaviour.This release carries a version one day ahead of the calendar, on purpose. r-universe already publishes
2026.8.15from commit89c51e13, which lacks these fixes. A second tree under that number would leave two sources sharing one version.
Documentation
-
vignettes/csdb.Rmdis no longer precompiled.vignettes/_PRECOMPILER.Randvignettes/csdb.Rmd.origare deleted, and.Rbuildignoreno longer names either one. - Every chunk in that vignette now executes during
R CMD check. A chunk witherror = FALSEturns the check red if it raises unexpectedly. The six chunks that demonstrate a raise carryerror = TRUE, and such a chunk passes whether it raises or not. - The vignette therefore guards against new unexpected errors. The expected errors and every printed value are demonstrations, and the test suites assert them.
- The vignette gains four sections, and a chunk that runs demonstrates each one. They cover connection ownership, the fork guard, index naming, and the destructive-method guard.
- The destructive-method section covers both methods and all four cases. A refused frame leaves three sentinel rows in place, which is what shows that the validator runs before the drop.
-
?DBConnection_v9said an inherited connection “returns wrong results and reports no error”. It CAN return wrong results. The private comment besidediscard_inherited_connection()carried the same absolute. - The vignette lost its “Overview” section, which restated “What csdb is for” and the two-class split immediately below them.
- The vignette no longer attaches
magrittr.magrittris in noDESCRIPTIONfield, and precompilation hid that, becauseR CMD checknever ran the chunk. -
?DBTable_v9gains two sections. “What the object creates in the database” names the table, thePK_constraint and the physical index name. “The case of a constraint name on PostgreSQL” carries the 92-to-0 measurement recorded above. -
?DBTable_v9said the introduction vignette builds a table against a PostgreSQL database. It builds one on SQLite. - Four
\dontrun{}example blocks now run on SQLite, under\donttest{}. They are on?DBConnection_v9,?DBTable_v9,?csdb_get_auth_hookand?csdb_set_auth_hook. - One
\dontrun{}block remains, on?get_table_names_and_info. It connects to a PostgreSQL server, so it cannot run here. That page also gains a SQLite block that does run. - The
drop_indexPostgreSQL comment said the schema is every component of the table identity except the last. The code takes the penultimate component, which is what PostgreSQL wants: an index lives in a schema, and a catalog-qualified index name is not valid. The comment is corrected and the code is unchanged.
Version 2026.8.15
Bug Fixes
-
DBConnection_v9no longer hands out a connection that another process opened. A fork copies the object and the open handle, so the child and the parent then use one socket. PostgreSQL answers with wrong results and reports no error.DBI::dbIsValid()reports TRUE on such a handle, so nothing detected it before. - The class records the process that opens each connection, and compares that process against the current one.
is_connected()reports FALSE after a fork,connectionreturns NULL, andautoconnectionopens a connection for the child. -
disconnect()never closes a connection that another process opened. Closing it would close the parent’s socket, which is the corruption this release prevents. - The object keeps a reference to an inherited handle. Without that reference, the garbage collector runs odbc’s finalizer and closes the parent’s socket anyway.
- This matters more from 2026.8.14 on, because one
DBConnection_v9now serves many tables.cs9::Task$run_parallel_plans()forks withpbmcapply::pbmclapplyand passes table objects into the workers. - Measured against the
norsyss-postgresserver on 2026-08-14: four forked children each got their own backend process ID and their own correct result. With the guard disabled, two of those four children returned the parent’s backend process ID, and two failed with a type error. - Same-process behaviour is unchanged. The 162 checks in the csdb suite and the 298 checks in the cs9 suite pass without an edit.
Development
-
tests/testthat/test-fork-safety.Rcovers the fork guarantee. Layer 1 changes the recorded process ID on a SQLite connection, and runs everywhere. Layer 2 forks withparallel::mcparallel, and skips on Windows, which has no fork. - The csdb suite now holds 206 passes, up from 162.
- This release carries a version one day ahead of the calendar, on purpose.
2026.8.14is already published from an earlier tree that lacks the fork guard, so a second tree under that number would leave two different sources sharing one version.cs9requirescsdb (>= 2026.8.15)for the same reason: that floor names the guard. - The
norsyss-postgresserver now allows 300 connections, measured 2026-08-14. The 97-usable figure below described the server on 2026-08-13, when the import failed.
Version 2026.8.14
New Features
-
DBTable_v9$new()takes adbconnectionargument. Pass an existingDBConnection_v9and the table uses it instead of building its own. The argument is last, so a subclass can still forward the earlier seven positionally. -
DBTable_v9$disconnect()closes only a connection the object built itself. A connection passed asdbconnectionis borrowed. The method leaves it open, and the caller decides when it closes. - One
DBConnection_v9can now serve many tables. The motivating case iscs9, which builds oneDBTable_v9for every partition of a partitioned table. A table with 106 partitions therefore opened 106 connections at once, against 97 usable slots on thenorsyss-postgresserver. This release only makes the sharing possible.cs9MUST pass the shared connection itself.
Version 2026.8.6
Licensing
- The copyright holder is now Folkehelseinstituttet. It read “Core Surveillance”, which names the package family rather than a legal entity.
-
DESCRIPTIONAuthors@Rnow declares that holder withrole = "cph". It declared no copyright holder at all, and neither did any other package in the fleet. Nothing inR CMD checkreports that. - The copyright year is now 2026. It read 2023.
-
CLAUDE.mdnow carries a Licensing section, so the year gets checked rather than silently ageing.
Documentation
Repository prose now follows ASD-STE100 (Simplified Technical English). The sweep covered the roxygen2 blocks in
R/, both vignettes, andREADME.md. It changed no claim and no executable code.index.mdneeded no change.No roxygen sentence runs over 25 words. Counted per authored unit, which is one
@description,@param,@return,@seealso, paragraph or Rd\item, the count fell from 6 to 0. The longest sentence fell from 36 words to 24.Roxygen fields and
\itemizeitems now end in a full stop. Without one, a sentence splitter runs straight through the field boundary and reports a merge as one sentence. That is where the 74-word to 97-word readings came from, in blocks whose longest authored sentence was under 25 words.vignettes/csdb.Rmd,vignettes/backends.RmdandREADME.mdare also at zero sentences over 25 words. The counts before were 3, 1 and 2.vignettes/csdb.Rmdandvignettes/csdb.Rmd.origcarry identical prose edits, so the generated file and its source stay in sync.vignettes/_PRECOMPILER.Rwas not re-run, and no chunk output changed.The v3 statement in the introduction vignette keeps its size.
csdbCAN store acsfmt_rts_data_v3, with the_blankpair or with a validator of your own. What is missing is a validator that knows the v3 shape.Ornamental adjectives are gone from the reference pages: “robust” from
DBConnection_v9, “sophisticated” and “comprehensive” fromDBTable_v9, and “comprehensive” fromget_table_names_and_info(). TheDBTable_v9title is now “R6 Class representing a database table”, parallel withDBConnection_v9.The introduction vignette opens with prose instead of with code output. pkgdown promotes
vignettes/csdb.Rmdto “Get started”, and the first thing on that page was thedata.tableattach message:Attaching package: 'data.table'and the%notin%masking line. A new “What csdb is for” section now comes first. It says what the package does, splitsDBConnection_v9(the connection) fromDBTable_v9(one table), tabulates the three backends, names the missing v3 validator, and says wherecsdbsits in the stack. Its two chunks run without a database server.The
library(data.table)andlibrary(magrittr)chunk is nowmessage = FALSE. No chunk in the vignette uses%>%or baredata.tablesyntax, so the two attach messages announced masking that nothing below them relied on. Thelibrary()calls themselves are unchanged.The overview states a current limitation plainly.
csdbexports field-type and field-contents validators forcsfmt_rts_data_v1andcsfmt_rts_data_v2and none forcsfmt_rts_data_v3;grep("v3", getNamespaceExports("csdb"))returnscharacter(0). That matters now rather than later, becausecstidymarks v1 and v2 deprecated in favour ofset_csfmt_rts_data_v3(), andcsalert’s pipeline ends inens_collapse(heal = TRUE), which returns acsfmt_rts_data_v3. A v3 result can still be written, with the blank validators or with a function of the user’s own, but nothing then checks its columns.The overview shows
is_connected()returningFALSEimmediately afterDBConnection_v9$new()and again afterDBTable_v9$new(). The second call uses a PostgreSQL configuration naming a server that is not running, which is the strongest form of the claim: neither constructor opens a connection.vignettes/csdb.Rmdwas regenerated fromvignettes/csdb.Rmd.origbyvignettes/_PRECOMPILER.R. Apart from the new section and the two suppressed attach messages, the only change in it is thetempfile()path, which differs on every run.
Bug Fixes
-
DBTable_v9$connect()was documented as “Connect from the database”. It connects to the database, which is whatDBConnection_v9$connect()already said. -
DBTable_v9$drop_indexes()was documented as “Drops all indees from the database table”. The word is “indexes”. -
DBTable_v9$insert_data()carried a stray prose line after@param verbose, so roxygen2 rendered theverboseargument as “Boolean. Inserts data into the database table”. That sentence is now the method’s@description, andverbosereads “Boolean.”
Version 2026.8.5
New Features
SQLite is a third backend.
driver = "SQLite"withdbset to a file path connects throughRSQLite, which is now inImports. The driver string is matched case-insensitively, sosqlite,SQLiteandSQLITEall select it; the two ODBC driver strings keep exact matching, because they must equal anodbcinst.inientry.DBConnection_v9creates the parent directory ofdbif it does not exist, then opens the file withextended_types = TRUE. That argument is required, not cosmetic: without it aDATEcolumn reads back as the integer18262rather than aDate, andvalidator_field_contents_csfmt_rts_data_v1()rejects it. NoUSE <db>;is issued, because the file is already the database.DBConnection_v9$print()shows the driver and the file path for SQLite, and omits server, port, user, password, SSL mode and trusted connection, none of which SQLite reads.DBTable_v9identifiers under SQLite are the bare table name:DBI::Id(table = <table_name>)and the plain string.schemais ignored entirely, because SQLite has no schemas.create_table()on SQLite inlines the primary key in theCREATE TABLEstatement and marks every key columnNOT NULL.add_constraint()is therefore a no-op there. SQLite has noALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY; the statement the other backends use is a syntax error.The SQLite field-type map is closed:
TEXT,INTEGER,DOUBLE,BOOLEAN,DATEandDATETIMEare accepted and anything else is an error naming the column and the type. SQLite accepts any declared type name, soVARCHAR(100),TEXT(100)or a misspelling would otherwise create a table with an unintended affinity and no warning.insert_data()on SQLite writes throughDBI::dbAppendTable(). There is no staging CSV and no external client binary: SQLite is a file, anddbAppendTable()writes 100,000 rows in about 0.02 seconds. Thefileargument is accepted and ignored.insert_data()on SQLite copies its argument before writing, so the caller’sdata.tableis not modified by reference. The three other backends reachwrite_data_infile(), which has always modified it in place.upsert_data()on SQLite stages the rows in a temporary table and then issuesINSERT ... ON CONFLICT (<keys>) DO UPDATE SET, falling back toDO NOTHINGwhen every field is a key. SQLite has neitherMERGEnorON DUPLICATE KEY UPDATE. Three preconditions are checked before any SQL is emitted, because each fails late and obscurely otherwise:keysmust be non-empty, or the statement isON CONFLICT (); every key must be one of the fields; andfieldsmust be exactly the table’s live columns, becauseCREATE TABLE ... AS SELECTdiscards defaults and a partial field list would insert NULL into every omitted column.drop_all_rows()is now an S7 generic. SQL Server and PostgreSQL keep theTRUNCATE TABLEstatement they always received, unchanged; SQLite getsDELETE FROM <table>, becauseTRUNCATE TABLEis a syntax error there.DELETEleaves the primary key and every index intact, which matters because the SQLiteadd_constraint()cannot put a dropped primary key back.keep_rows_where()on SQLite emitsDELETE FROM <table> WHERE (<condition>) IS NOT TRUE, notNOT (<condition>). The two are not the same statement:DELETEremoves only rows whose predicate evaluates to TRUE, and the negation of NULL is NULL, so a plain negation silently retains every row on which the condition is NULL, althoughSELECT ... WHERE <condition>would not have kept it.IS NOT TRUEfolds NULL into FALSE and gives the exact complement. It is also aDELETErather than the drop-and-rename the other two backends use, for the same primary-key reason asdrop_all_rows().drop_rows_where()on SQLite emitsDELETE FROM <table> WHERE <condition>.add_indexes()on SQLite emitsCREATE INDEX IF NOT EXISTS <index> ON <table> (<keys>), with the table name unqualified. SQLite lets the index name carry a schema but never the table:CREATE INDEX ind ON main.tab (a)isnear ".": syntax error.drop_indexes()emitsDROP INDEX IF EXISTS <index>, which names the index alone, because a SQLite index belongs to the schema rather than to the table.confirm_indexes()on SQLite now executes no DDL when the indexes already match.get_indexes()excludes SQLite’s own index names and orders byrowid. Both are required: aPRIMARY KEYauto-createssqlite_autoindex_<table>_1, which is a row insqlite_masterexactly like a user index, androwidorder is creation order, which is the orderadd_indexes()works in.confirm_indexes()compares withidentical(), so an extra name or a different order would drop and re-add every index on every call. The return value is a plain character vector for the same reason.get_table_names_and_info()has a method forSQLiteConnection.nrowisCOUNT(*), which is exact, unlike thereltuplesestimate PostgreSQL reports and thesp_spaceusedfigure SQL Server reports. All three size columns areNA_real_: thedbstatvirtual table is not compiled into the SQLite thatRSQLiteships, so there is no per-table size to report, andpragma page_countdescribes the whole file. An empty database returns a zero-row table that still has all five columns.Both SQLite catalogue filters write the exclusion as
name NOT LIKE 'sqlite\_%' ESCAPE '\', escaping the underscore._is a single-character wildcard in SQLLIKE, so the unescaped'sqlite_%'hides every name beginning “sqlite” followed by any character at all, not only SQLite’s own objects. A user index namedsqliteIdxwould never be found byget_indexes(), andconfirm_indexes()would drop and re-add it on every call; a user table namedsqliteFoowould be missing fromget_table_names_and_info(), soDBTable_v9$nrow(use_count = FALSE)andDBTable_v9$info()would report nothing for it.
Known limitations
-
confirm_indexes()compares index names only. An index with the right name and the wrong columns passes. This is the existing behaviour of all three backends and SQLite matches it.
Documentation
- The introduction vignette now runs on SQLite, in a file created by
tempfile(). It is precompiled fromvignettes/csdb.Rmd.orig, and that precompilation used to need a live PostgreSQL database.knitr::knit()defaults toerror = TRUE, so on a machine without one it did not fail: it exited 0 and wrote seven#> Errortranscripts into the committedvignettes/csdb.Rmd, including aCould not connect to database server ''. Anyone can now rebuild the vignette and get the same output. - Added
vignettes/backends.Rmd, which puts a PostgreSQLdbconfigand a SQLitedbconfigside by side, runs oneDBTable_v9$new()definition against each, and tabulates what a user must know:schemais ignored, the primary key is inlined atCREATE TABLEand cannot be added later, an unrecognised field type is rejected rather than passed through,get_table_names_and_info()reports an exactCOUNT(*)andNAsizes, and no external client binary is needed. No chunk in it executes. -
README.md’s quick start is now the SQLite one, so it runs on a bare machine, and it links to both vignettes. The$keep_rows_where()caution is qualified: the copy, drop and rename it describes is the ODBC path, not the SQLite one. -
index.mdand the_pkgdown.ymlhero lede both name SQLite alongside PostgreSQL and SQL Server.
Development
- Added
tests/testthat/test-sqlite-connection.R, the first tests in the package that open a database connection. SQLite is a file, so they need no server. - Added
tests/testthat/test-sqlite-indexes.R. The block that provesconfirm_indexes()emits no DDL readsPRAGMA schema_versionbefore and after, not the index names: the names are identical whether the call did nothing or dropped and recreated every index, andschema_versionincrements on every schema change. A separate block creates an index namedsqliteBarand a table namedsqliteFooand asserts both are visible, which is what pins theESCAPEclause on the two catalogue filters. - Added
tests/testthat/test-sqlite-data.R, covering the five write and delete paths: type round-trip, the non-finite scrub, the caller’s data.table being left alone, upsert update-not-duplicate, the three upsert preconditions, the NULL-condition row,drop_all_rows()leaving the indexes, and identifiers that need quoting. - The
Inf/NaNtoNAloop moved out ofwrite_data_infile()into an internalscrub_non_finite(), called from there and from the SQLite write path.InfsurvivesDBI::dbAppendTable()and reads back asInf, so without it SQLite would silently disagree with the two backends that writeNA. ThePOSIXtto character conversion is not shared: SQLite needs aPOSIXctto stay one, so thatextended_types = TRUEround-trips it through aDATETIMEcolumn. -
dbplyris inImports. It always was a hard requirement and was never declared:DBTable_v9$tbl()callsdplyr::tbl()on a DBI connection, which dispatches todplyr:::tbl.DBIConnection()and stops incheck_dbplyr()when dbplyr is absent. Three documented methods go through it,tbl(),print_dplyr_select()andnrow(use_count = TRUE), andtbl()is the only read path the package offers, so a csdb without dbplyr is write-only. The gap never surfaced because nothing in csdb calledtbl()until the SQLite tests did; on a library without dbplyr those seven blocks error and the other 108 assertions pass.Suggestswas rejected on measurement: dbplyr adds three packages to anImportsclosure of 42, and the alternative is to make the package’s only read path optional. No csdb code names dbplyr, sofix_dbplyr()inR/xxx_small_import_fix.Rholds adbplyr::reference for the same reasonfix_r6()andfix_s7()hold theirs: without itR CMD checkreports “All declared Imports should be used”. -
RSQLiteis inImportsand has no S3 fallback inget_db_classes(), which stops with a message naming RSQLite if the real S4SQLiteConnectionclass is absent. AS7::new_S3_class()fallback would be worse than useless: with the real S4DBIConnectiondefault present, methods registered against the fallback lose dispatch silently and run the MySQL-flavoureddb_defaultSQL, and registering the real class later does not retarget them. - Documentation is generated by roxygen2 8.0.0.
DESCRIPTIONnow declaresConfig/roxygen2/versionin place ofRoxygenNote, and every.Rdfile was regenerated by that version.NAMESPACEis unchanged.
Version 2026.8.4
Documentation
-
README.mdnow carries what the package is, installation, one quick start, and a table that routes a task to the function that does it. It also states two things the API does not do:create_table()drops and rebuilds a table whose columns differ fromnames(field_types), and no method opens a transaction. - All 11 exported functions gained a
@seealsothat says whether the introduction vignette demonstrates them. Four appear in a vignette code chunk (DBConnection_v9,DBTable_v9,validator_field_types_blank,validator_field_contents_blank); the other seven appear nowhere in the vignette, and their@seealsosays so. - Added three
@familygroups: auth hook functions (both address thecsdb.auth_hookoption, one writing it and one reading it), field type validators (onedb_field_typesargument, checked once insideDBTable_v9$new()), and field contents validators (onedataargument, called frominsert_data()andupsert_data()).DBConnection_v9andDBTable_v9are grouped as database classes:DBTable_v9$new()takes adbconfiglist of exactly the 10 argumentsDBConnection_v9$new()accepts, and builds one.
Bug Fixes
-
get_table_names_and_info(): the documented PostgreSQL example connected throughRPostgres::Postgres(). Those connections are of classPqConnection, and the generic has methods forPostgreSQLandMicrosoft SQL Serveronly, so that example cannot dispatch; it errors with “no applicable method”. It now connects through thePostgreSQL UnicodeODBC driver, which is the class the methods are written for.RPostgreswas also absent fromImportsandSuggests. -
get_table_names_and_info(): thenrowcolumn was documented as the number of rows. It isreltuplesfrompg_classon PostgreSQL, which is an estimate, and therowscolumn ofsp_spaceusedon Microsoft SQL Server. Documented as reported, not as exact. -
DBConnection_v9: the documented PostgreSQL example useddriver = "PostgreSQL". Only"PostgreSQL Unicode"selects a PostgreSQL branch in the connection code, so"PostgreSQL"falls through to the generic branch, which does not passdatabase, and is then followed byUSE <db>;. Changed to"PostgreSQL Unicode". -
validator_field_types_csfmt_rts_data_v2(): the example vector labelled “Valid field types” returnedFALSE, because it omittedisoquarterandisoyearquarter, which the v2 schema holds at positions 11 and 12. The example now returnsTRUE, and a second call shows the v1 layout returningFALSE. -
DBTable_v9: the documented example called$add_indexes(c("name", "date_created")), but that method takes no arguments and readsself$indexes. Indexes are now declared in the constructor. The same example passeddata.frames to$insert_data()and$upsert_data(), both of which reachdata.tablesyntax ([ , (col) := ],with = FALSE) and require adata.table. Changed todata.table::data.table().
Development
-
csdb_set_auth_hook(),DBConnection_v9andDBTable_v9gained runnable examples for the parts that need no database server: setting and restoring the hook, and creating an object without connecting. Their\dontrun{}blocks keep the parts that need a server. - Added
^Rplots\.pdf$to.Rbuildignore.
Version 2026.5.13
CRAN release: 2026-05-13
Bug Fixes
-
DBTable_v9$nrow(use_count = TRUE)now callsdplyr::n()instead of a baren(). This is hygiene only: the bare call sits inside the list passed toR6::R6Class(), whichcodetoolsnever walks, so it produced noR CMD checkcomplaint, anddbplyrrenders both spellings to identical SQL. - PostgreSQL methods (
create_table,keep_rows_where,drop_table) now quoterole_create_tableviaDBI::dbQuoteIdentifier()when emittingSET ROLE. Previously the role name was interpolated raw, which broke on identifiers containing hyphens, mixed case, or reserved words (e.g.SET ROLE token-user-> syntax error), and was a SQL-injection vector if the value came from an env var.
Version 2026.2.2
CRAN release: 2026-03-31
New Features
- Added authentication hook system (
csdb_set_auth_hook(),csdb_get_auth_hook()) to allow automatic credential refresh (e.g., Kerberos tickets) when connection fails
Version 2025.7.19
Version 2025.7.17
- Updated package for CRAN submission with comprehensive improvements
- Added comprehensive documentation with examples for all exported functions
- Fixed critical CRAN compliance issues including system tool availability checks
- Added proper R6 class documentation with detailed usage examples
- Improved all validator function documentation with clear examples
- Added missing dependencies and fixed import declarations
- Updated .Rbuildignore to exclude system files and build artifacts
- Added CLAUDE.md for future development guidance
- Fixed vignette title and improved documentation quality
- All functions now pass R CMD check with only acceptable NOTEs
Version 2024.10.25
-
role_create_tableis now included for dbconnection_v9/dbtable_v9, so that the role can be changed when creating tables in PostgreSQL.
Version 2024.3.11
- Including use_count as an argument in nrow in DBTable_v9, which is slower but more accurate.
Version 2024.3.7
- Including confirm_insert_via_nrow in DBTable_v9. Checks nrow() before insert and after insert. If nrow() has not increased sufficiently, then attempt an upsert.
Version 2023.12.28
- Including validator_field_types_csfmt_rts_data_v2 and validator_field_contents_csfmt_rts_data_v2.
Version 2023.4.12
-
get_table_names_and_nrowis now changed toget_table_names_and_infoand also includes size_total_gb, size_data_gb, size_index_gb. -
infois now included as a method forDBTable_v9
Version 2023.4.4
-
confirm_indexesis now added toDBTable_v9, which confirms that the names and number of indexes in the database are the same as in the R code. It does not confirm the contents of the indexes! -
nrowis now added toDBTable_v9, which is an application of the newget_table_names_and_nrowfunction. -
get_table_names_and_nrowadded as an exported function, that will get all the table names and the nrows from a dbconnection.
