0023: Schema changes on the source, and switching to a newer capture instance¶
Status: accepted
Date: 2026-09-30T18:40:00-03:00
Amended: 2026-09-30T20:09:44-03:00, run against SQL Server 2022 (tests/integration); a type bronze cannot take names delta.enableTypeWidening
Amended: 2026-09-30T21:25:30-03:00, drop the older instance one batch after the switch event; schema checked at an instance's first read; uncaptured declared columns fail; detail is the DDL statement
Amended: 2026-10-01T15:55:00-03:00, type widening on silver is a manual ALTER COLUMN ... TYPE: apply_changes merges without schema evolution
Amended: 2026-10-01T17:25:27-03:00, lab check t9 runs the switch under a continuous writer on SQL Server 2022 and 2017; the production SQL Server 2016 SP3 change tables have __$command_id
Amended: 2026-10-01T18:15:53-03:00, the switch tests run with the arrow-odbc backend too (ADR 0003 Amendment 2)
Amended: 2026-10-07T01:41:18-03:00, the reader's other warnings (columns not read, unknown options, a fixed UTC offset) reach the batch's facts row as detail {"warnings": [...]} (Amendment 6)
Amended: 2026-10-13T18:45:19-03:00, snapshot_on_switch in chunked mode: the switch batch opens a chunked snapshot at the newer instance's start LSN, which backfill() reads next to the stream (Amendment 7)
Amended: 2026-10-15T09:50:28-03:00, a capture_instance_switched row's detail is JSON: old, new, start_lsn, not_captured and the former line as text (Amendment 8)
Context¶
A capture instance captures a fixed column list, chosen when it is enabled. To capture a
column added to the table, SQL Server's way is a second capture instance of the same table
(at most two), then disabling the first. Until now the library read one capture instance
with the schema of load() and knew nothing about DDL. Measured on SQL Server 2022 CU27
and 2017 CU31 in Docker, library at 03a0f3a, with a least-privilege login (SELECT on the
source table and on cdc.<ci>_CT only):
- ADD COLUMN: the capture instance does not change (captured columns and change table
stay);
cdc.ddl_historygets a row withrequired_column_update = 0; an UPDATE that touches only the new column writes no change row. The stream kept going and the new column's changes were lost until a new instance existed. - DROP COLUMN of a captured column:
sp_cdc_get_captured_columnsstill lists it and the change table keeps it; rows captured after the drop have NULL there (before-images too); a commit made just before the DROP but captured after it kept its value; addl_historyrow (0). The stream continued and bronze got NULLs, butsnapshot(),bootstrap=Trueandon_data_loss="resnapshot"failed withInvalid column name: the snapshot selected the captured columns from the source table. - ALTER COLUMN type (
decimal(9,2)to(18,4),varchar(10)to(50),inttobigint):sp_cdc_get_captured_columnsreports the new type, the change-table column is altered and its rows converted, and theddl_historyrow hasrequired_column_update = 1. A running query kept its load-time schema: it wrote the values that fit and failed on the first that did not (pyarrowArrowInvalid: Rescaling Decimal value would cause data loss). On restart the schema was re-inferred and the Delta append failed withDELTA_FAILED_TO_MERGE_FIELDS; withdelta.enableTypeWidening = trueon bronze plus schema merging, Delta widened the column and wrote. - Other DDL: a NOT NULL change writes a
ddl_historyrow (0). Renaming a captured column is refused (REPLICATED). Renaming an uncaptured column or the table is allowed and not recorded; after a table renamesp_cdc_help_change_data_captureshows the new name under the old instance. TRUNCATE and altering the key column are refused. Adding back a dropped column's name with another type makes a newcolumn_id: captured and source columns must be compared bycolumn_id, not by name. - Visibility: capture writes
ddl_historyasynchronously, but over 2,746 samples and 8 DDLs there was no case wheremax_lsn >= ddl_lsnand the row was not yet visible throughsp_cdc_get_ddl_historyto the least-privilege login: a batch that ends at or belowmax_lsnsees its DDLs.ddl_timecomes in whole minutes. - Permissions: with SELECT on the source table the login can call
sys.sp_cdc_get_ddl_history,sp_cdc_get_captured_columns,sp_cdc_help_change_data_capture(which lists both instances of the table),fn_cdc_get_min_lsnandfn_cdc_get_column_ordinal; it cannot readcdc.ddl_history,cdc.change_tables,cdc.captured_columnsorcdc.index_columns. With only the change-table grant the procedures say "Object does not exist or access is denied", the help procedure returns nothing andmin_lsnis0x00. A new instance's change table needs its ownGRANT SELECT. - A second capture instance: its
start_lsnS is the commit LSN of the enable (incdc.lsn_time_mapping). Right after the enablemax_lsnis below S andfn_cdc_get_min_lsn(v2)is0x00until capture processes the enable (2.9 s under load; up to ~5 minutes on a quiet database, ADR 0016). Under 50 inserts/s, of 685 commits, 259 were only in v1 (all with LSN < S), 426 in both (all >= S, with identical(start_lsn, seqval)), none only in v2 and none in neither. So reading v1 below S and v2 from S reads every commit once. The enable waits while a transaction that already wrote the table is open (3.4 s; that commit is only in v1, below S); a transaction that began earlier but writes only after the enable does not block it and is complete in both.__$command_iddiffers between the instances for the same change (v1 1, 3, 3, 5; v2 2, 4, 4, 6), each keeping the order:(start_lsn, seqval, operation)identifies a change across instances,command_iddoes not. A third instance is refused.sp_cdc_disable_tableon v1 takes effect at once: its change table is gone,min_lsnis0x00, itsddl_historyrows are deleted and readers getInvalid object name. DDL made before v2 existed is not in v2's history. Documented only: cleanup movesstart_lsnof every instance up to its new low watermark, so two instances can share one; Debezium breaks that tie bycreate_date. - The library before this change: Spark accepted a changed source schema on restart with
the same checkpoint (
foreachBatch). SwitchingcaptureInstanceto v2 before the stream had read v1 up to S raisedDataLossError(right), but blamed CDC cleanup; after catching up on v1 the switch worked, since offsets are database-wide LSNs. v2's new column failed the bronze append withDELTA_METADATA_MISMATCH(nomergeSchema), and a column v2 lacked was filled with NULL by Delta. A rerun withbootstrap=Trueafter the switch wrote a second full snapshot: the lookup filtered_capture_instance = ci, as did the ADR 0018 recovery. Once v1 was dropped, a stream still configured for it failed atload()with "not found", not naming v2. - Others: Debezium's SQL Server connector leaves the new instance to the DBA, cuts the
old one at the new one's
start_lsn(the same rule), emits a notification, and drops nothing; it readscdc.change_tables, which invariant 11 rules out here. Estuary switches to the newest instance withstart_lsn <=its position, and can manage instances itself withdb_owner. Lakeflow Connect uses a DDL trigger (db_owner). Fivetran reads one instance. Fabric mirroring fails on DDL and reseeds. AWS DMS reads the log instead.
Decision¶
The offset contract does not change (invariant 1): every LSN is database-wide, so which
capture instance holds a range follows from the instances' start_lsn, computed again each
time a batch is planned, replays included.
D1. Detect DDL on the driver, every batch, and react by kind¶
- When planning a batch, the reader asks
sys.sp_cdc_get_ddl_history(<ci>)of each instance the batch reads for entries withddl_lsnin(start, end](CdcClient.ddl_history), and compares the captured types (sp_cdc_get_captured_columns) with the query's schema by name (withcolumns, with the captured types at the run's first planning, since the declared ones are the user's own conversions). - A type change of a captured column that the query's type can no longer hold fails the
planning of the batch that contains it, before any row is read, with
SchemaChangedError(aRuntimeError, exported): "restart the query to re-infer the schema (and enabledelta.enableTypeWideningon bronze for a widening)".required_column_update = 1alone does not fail it: the batch replayed after the restart holds the same DDL, and would fail forever; a change that still fits (varchar(10)to(50), both STRING) is aschema_changeevent. - ADD and DROP of columns and any other DDL: the stream continues, logs a warning, and
leaves a
schema_changeevent for the facts (below). - Option
schemaChangePolicy:classify(the default, as above) orfail(any DDL in the batch fails it; the replayed batch fails again, so the message says to restart once withclassifyto go past it). - Considered:
- Diffing
sp_cdc_get_captured_columnseach batch alone: it sees type changes only; an ADD leaves the captured list as it was and a DROP stays listed. - Reading
cdc.ddl_historyor a DDL trigger on the source: the first needs a grant beyond invariant 11, the seconddb_ownerand writes to the source (Lakeflow Connect). - Failing on every DDL (Fabric mirroring): a NOT NULL change, or a column nobody captures, would stop ingestion for nothing. Ignoring DDL, as before: the type change failed mid-batch in an executor with an Arrow error, after some rows were written.
sp_cdc_get_ddl_historyis documented, readable by the least-privilege login, and was never behindmax_lsnin the measurements.
D2. Follow a newer capture instance of the same table¶
- The reader lists the table's instances with one
sys.sp_cdc_help_change_data_capturecall without arguments, the listingsource_tablealready reads, which applies the same permission check per table (CdcClient.capture_instances, oldest first); newer means a latercreate_date(a tie onstart_lsnafter cleanup is broken by it). partitions()cuts[from, to]at S, the newer instance'sstart_lsn: the older instance for[from, S - 1](clamped to the range), the newer for[S, to], never an empty range (invariant 3). EachLsnRangenames its capture instance, so executors stay stateless (invariant 5), and the retention guard runs per instance: a range of the older instance checks the older'smin_lsn(invariant 4).- Schema at
load(): the union by column name of the instances' captured columns, in capture order, older first. Two types for one name resolve to the newer's when it holds the older's values; otherwiseload()fails withSchemaChangedError. A range selects typed NULL for a column its instance lacks. - Ordering across instances is
(_start_lsn, _seqval, _operation):_command_idnumbers each instance's rows its own way, so it breaks ties only within one_start_lsn, which the cut at S keeps within one instance. The existing key(_start_lsn, _command_id, _seqval, _operation)stays right for the same reason; the bronze column comment says so. - A newer instance that appears while a query runs: the query switches in place when its
schema holds every column the newer instance captures, with a type that fits, and otherwise
fails with
SchemaChangedErrorat the boundary, before reading past S, so that the nextload()infers the new columns. Withcolumnsthe declared list decides: the switch is in place, with a warning naming captured columns it leaves out. - A configured instance that was dropped:
load()and planning follow the newest instance of its table (found through its default name<schema>_<table>, or through the newest name the run has seen); otherwise the not-found error names the table's instances. DataLossErrornames the dropped older instance beside cleanup when a gap is in the table's oldest remaining instance and the run has seen an instance the table no longer has (amendment: where the gap lies does not tell the two apart); aPermissionErroron the newer change table names its own grant.- The reader leaves a
capture_instance_switchedevent (detailold -> new, plus the columns the query reads that the newer instance no longer captures, NULL from S on) when it plans the first batch that reads the newer instance. The older one can be dropped after the stream's next batch, not before (amendment): the sink commits the event row insideforeachBatch, before Spark commits the batch (and before thesnapshot_on_switchsnapshot), and a replay of that batch reads the older instance below S again. Moving the event a batch later would need state across runs to emit it once, andsnapshot_on_switchneeds the batch that crosses S. A stream that starts at or past S (a bootstrap after the enable,startingLsn=latest) never reads the older instance and leaves no event. - A query's schema is checked against an instance's columns the first time the run reads it
(amendment), not only at S: a stream that skips ahead into the newer instance
(
failOnDataLoss=falseafter the older was dropped) fails the same way. A source column no instance of the table captures (a typo incolumns, a column outside@captured_column_list) fails the run's first planning and the snapshot with aValueErrorinstead of reading NULL in every change row. - Considered:
- The user changes
captureInstanceby hand (the state before): it needs the stream to have read exactly up to S first, or it fails, and every consumer does it at its own moment. - A new checkpoint or stream per instance: a second checkpoint (invariant 8 needs a new
app_id) and a gap or an overlap to reconcile at S. - Estuary's rule, the newest instance with
start_lsn <=the position, switches only at a batch boundary; cutting inside the batch lets one batch span the switch.
D3. The DBA creates and drops instances; the library never does¶
sql/switch_capture_instance.sql documents the procedure: enable the new instance with the
new column list, GRANT SELECT ON cdc.<new>_CT TO <reader>, wait for every stream's
capture_instance_switched event and one batch more, then disable the old one. Considered:
letting the library manage instances (Estuary): it needs db_owner and writes to the source, against invariant 11.
D4. Bronze takes new columns; type widening is the owner's choice¶
Every bronze append, micro-batches and snapshots alike, uses mergeSchema: a column the
newer instance captures is added (older rows read NULL), and a column the batch lacks is
NULL. A type change fails the append unless the bronze table has
delta.enableTypeWidening = true and the change is a widening Delta supports; the library
documents the property and never sets it. The failed append raises SchemaChangedError
naming the property and the ALTER TABLE that sets it (amendment): Delta's own error,
[DELTA_FAILED_TO_MERGE_FIELDS] Failed to merge fields 'amount' and 'amount', says neither.
Considered: overwriteSchema (a rewrite of the
table) and casting new types back to the old ones (silent truncation).
D5. Rows unchanged since a switch keep NULL for a newly captured column¶
The latest image of a row that did not change after S has NULL for a column only the newer
instance captures. to_delta(..., snapshot_on_switch=False): with True, after the batch
that first reads the newer instance (the capture_instance_switched event), the sink appends
a snapshot (ADR 0016's machinery, stamped with max_lsn recorded before its read), so the
latest image carries the new column's values. Considered: always snapshotting at a switch (a
full read, too slow for large tables, see
tables too big to snapshot) and
never (a column that stays NULL until each row changes).
Needed whichever option¶
- The snapshot reads typed NULL for a captured column the source table no longer has,
matched by
column_id(CdcClient.present_columns), instead of failing. - Existing snapshots are looked up by the set of capture instances of the same source table,
case-insensitive (
pipeline._instances):snapshot(),bootstrap=Trueand the ADR 0018 recovery. The recovery's retention test uses themin_lsnof the instance the source reads the next changes from: the newest one starting at or before them, else the oldest. - Silver (ADR 0019) reads the rows of that set: with
options, the table's instances come from SQL Server. A row in the range to apply of any other instance fails the call, naming it, instead of being skipped. A column bronze gained is added to silver. - Facts events. The reader, which cannot write Delta, leaves one JSON file per event in the
metrics directory (
metricsPath),event-<kind>-<lsn>.json(ADR 0018's'data_skipped':event-data_skipped-<ci>-<from>.json) withevent,capture_instance,lsn,commit_tsanddetail. The sink writes each as a facts row of the batch, in the same Delta commit as the batch's own row (txnAppId<app_id>#facts,txnVersionthe batch id), so a replay writes both or neither, and removes the files only after that commit; a file with the same kind and LSN is one event. The row haseventset to the kind, the batch'sbatch_id,rows = 0,min_lsn = max_lsn = end_lsnthe event's LSN,end_commit_tsits commit time,lost_*NULL, and the new columndetail(facts migration 6), which also rewrites the comments ofbatch_id,rows,event,end_lsnandend_commit_ts. WithoutmetricsPath, events are warning logs only. - Considered: a separate
txnAppIdper event kind (a second event of one kind in a later batch needs a new version anyway) and writing from the driver (the reader has no Spark session, and the rows would land before the batch's data). - Bronze migration 1 gives existing tables the new comments of
_capture_instance(the instance each row actually came from) and_command_id(numbered per instance).
Consequences¶
- A facts consumer that took
event IS NOT NULLfor "a snapshot" (as ADR 0018 and the README suggested for the rebuild point) must now filterevent IN ('bootstrap', 'resnapshot'):schema_changeandcapture_instance_switchedrows carryrows = 0and abatch_id. Statistics over micro-batches still filterevent IS NULL. Silver and the tests' helpers do. - A type change needs a restart; a widening also needs
delta.enableTypeWideningon bronze. Silver needs more (amendment 3):apply_changesadds new columns but does not change types, and its MERGE runs without schema evolution, so the property alone leaves silver's column at the old type; its owner widens it withALTER TABLE ... ALTER COLUMN ... TYPE. A narrowing or an incompatible change needs a new bronze table or a rewrite. - The newer instance's change table needs its own grant before the stream reaches S; the
first read without it raises a
PermissionErrornaming it. - The older instance can be dropped once every stream reading the table has a batch after its
capture_instance_switchedevent (a facts row of the sameapp_idwith a largerbatch_id); a stream already at or past S needs none. The event reaches the facts only throughmetricsPath, whichto_deltadefaults only for a local or Volume checkpoint. Dropped earlier, the changes below S that only it held are lost:DataLossErrornames cleanup and the dropped instance as the two possible causes (where the gap lies does not tell them apart), andon_data_loss="resnapshot"recovers with a snapshot. - With
columns, the D1 type check compares with the captured types at the run's first planning, so it catches a type change made while the query runs. One made while it is stopped is not caught at planning: the read fails casting to the declared type, andcolumnsneeds the new type. - Planning asks one
sp_cdc_help_change_data_captureand, per instance read, onesp_cdc_get_ddl_historyand onesp_cdc_get_captured_columnsmore per batch. - After the old instance is dropped and
captureInstancerenamed to the new one, SQL Server no longer lists the old name: rows stamped with it, a bootstrap snapshot included, match nothing. Abootstrap=Truererun then takes one more snapshot (harmless, only slower), and a silver table built from scratch fails loudly on the old rows (keep the old name configured, as the stream follows it). ponytail: record the table's instance names in the facts events and match on those if this shows up. - SQL Server 2016 SP3: the
sp_cdc_*procedures used here exist with no documented per-version differences.__$command_idcame with KB3030352 (2016 RTM CU5, SP1 CU2), and the production change tables have it (amendment 4: read through the library on 2026-09-30).CURRENT_TIMEZONE_IDis absent (the ADR 0008 fallback applies). 2016 left extended support on 2026-07-15. - Lab check t9 (amendment 4) runs the switch end to end on SQL Server 2022 CU27 and 2017
CU31: a continuous writer,
sql/switch_capture_instance.sqlas written, a least-privilege login, the running query stopped at S and restarted, the old instance dropped after the event and one batch more. Bronze held every change once, its latest image equalled the table, the stream kept running. - Unverified: the switch on SQL Server 2016; Databricks;
a tie on
start_lsnafter cleanup (documented, not reproduced);ddl_historyvisibility is measured (8 DDLs), not documented. - Tests:
tests/test_delta_sink.py(event files folded once across a replay,mergeSchema, bronze migration 1, a switch adding a column with its events and no second bootstrap snapshot, following the new instance after the old is dropped,snapshot_on_switch, a snapshot after a DROP COLUMN, a re-snapshot after the old instance was dropped too early, the type widening message) andtests/test_silver.py(silver across a switch, with and withoutoptions).tests/integration, against SQL Server 2022 with a least-privilege login (amendment): a new instance enabled under a continuous writer (every commit once, below S from the old instance), the old one disabled too early (DataLossError, thenon_data_loss="resnapshot"), a type change stopping a running query before its batch is written and widened on restart, bootstrap and re-snapshot after a DROP COLUMN, an ADD COLUMN reaching bronze through a new instance withsnapshot_on_switch, the missing grant on the new change table, and a transaction open during the enable (only in the old instance, below S). They also pinned that an update of only a column an instance does not capture writes no change row there; the fake now does the same.
Amendment 6: the reader's other warnings reach the facts¶
The reader logs on the driver, to the stderr of the Python worker Spark runs the source in,
which jobs rarely keep. Changes on the source were already event rows; the warnings about
what the query reads were in that log only: captured columns that columns leaves out at a
newer instance (D2), computed columns left out of the inferred schema or listed in columns
(ADR 0007), unknown options, and the UTC offset
it converts commit times with on a server older than SQL Server 2022
(ADR 0008).
- The reader keeps each until it plans a batch with a range to read. The batch's last range
carries them to its task, which writes them in its metrics file, and the sink puts them in
the
detailof the batch's own row as JSON{"warnings": [...]}, only when there are any (facts migration 12, a comment). They ride the batch's facts commit, so a replay writes them once, and a retried task rewrites its file with them. WithoutmetricsPaththey stay in the log. - A restarted run's first batch can replay one whose facts commit was written before the
crash, which Delta then skips with the new run's warnings in it. The sink knows it from the
facts (the batch id is the largest the
app_idwrote, as its resume check reads) and puts them in the next batch's row instead. - A warning is recorded as often as it is logged: once per run, and the offset again when it
changes. One logged elsewhere (the computed columns by
load(), the offset by the client) is recorded by the reader, not logged again. - Retries on a new connection (ADR 0029) stay in the log: the batch went through.
- Considered: an event row per warning, a new
eventvalue for every consumer's filters to know where the batch's row hasdetailfree; and an event file written while planning, which the sink folds into whatever batch comes next, a dead attempt's included, where a dead attempt's partition files are removed before the read. - Tests:
tests/test_reader_units.py(each warning on the batch's last range, once, and in its task's metrics file) andtests/test_delta_sink.py(a misspelt option and a column a newer instance captures reach the batch's row once across a replay; a restart's warnings reach the next batch's row when its first batch replays one already in the facts).
Amendment 7: the snapshot after a switch, chunked¶
D5's snapshot reads the whole table inside the batch that read the switch, and the stream
waits for it, so to_delta refused snapshot_on_switch=True with snapshot="chunked": the
tables too big to snapshot, the ones that take chunked snapshots, kept NULL in the new column
until each row changed. Now, with snapshot="chunked", that batch only opens a chunked
snapshot (ADR 0028) and backfill() reads it
next to the stream, as a chunked bootstrap; with "full" nothing changes.
- Its S is the newer instance's start LSN, the switch event's
min_lsn, notmax_lsnafter the batch as the full snapshot takes. The batch read past it, and its changes are in bronze before the snapshot opens, so the stream has every change after S, as a generation has after its chunked snapshot's S. Silver's rebuild (the rows of S and the changes after S) andreconcile's position (a chunked snapshot's S, which the stream has read up to: ADR 0028 Amendment 2) hold as they are. Every chunk is stamped withmax_lsnbefore its wave, after the batch's end and so after S; S is recorded before any row is read, but is notmax_lsn(invariant 12). S lies after the batch's start, which is at or after the S of any chunked snapshot of the generation: it is the newest, and supersedes one still open, as a newer open does (backfill(), silver andreconciletake the newest); that one's chunks are read again, now with the new column. - Its 'snapshot_open' row has
kind'bootstrap'(no change was lost: a'resnapshot'row is what monitoring alerts on), the running generation, and a new keyswitch_batch_id, thebatch_idof the switch's 'capture_instance_switched' row. The generation's own Delta keys may be taken by its bootstrap or re-snapshot, so the open is keyed by S,<app_id>#snapopen.<S>, 0, as its plans table row, andbackfill()writes the completion, a'bootstrap'row, as<app_id>#snapdone.<S>, 0 when the open hasswitch_batch_id. A replay of the switch batch opens it at the same S: Delta skips both rows, and it is read back. - One mode per run: a chunked run with
snapshot_on_switchchecks the lock at its start (ADR 0028), and the open snapshot holds the mode for the runs andbackfill()calls after it. - State: facts migration 14 and plans migration 1, comments only (
eventanddetail), and the added key (ADR 0021 amendment 10). - Considered: S at
max_lsnafter the batch, as the full snapshot: above the stream's position, soreconcilewould need the facts to tell a switch's S from a generation's, and a commit in between would be in neither bronze nor its read of the change table. A newkindor event ('switch'): every consumer that filtersevent IN ('bootstrap', 'resnapshot'), and an older release's silver, would miss its completion. Keeping the open chunked snapshot and reading only the rest: its chunks read before the switch lack the new column. - Tests:
tests/test_silver.py(a chunked snapshot opened after a switch, read bybackfill()in two calls with a delete and an update between them: silver equal to the source with the new column; its completion next to the bootstrap's in one generation; the open read back when the switch batch replays; a full run refused while it is open; a rerun opens nothing),tests/test_migrations.py(plans migration 1) andtests/integration(against SQL Server 2022, thesql/switch_capture_instance.sqlsteps with a chunked bootstrap: the snapshot opened at the new instance's start LSN, silver equal to the table with the new column afterbackfill(), and again after the old instance is disabled).
Amendment 8: the switch's detail as JSON¶
The switch's detail was a line for people (old -> new, then the columns no longer
captured), so a consumer that wanted the instance names, to wait for a switch in step 4 of
sql/switch_capture_instance.sql or to alert on uncaptured columns, parsed text that may
be reworded in any release (ADR 0021 amendment 11).
From 1.0.0rc1 the reader writes it as JSON, through events.switch_detail:
{"old", "new", "start_lsn", "not_captured", "text"}, text the former line, so every piece
it held is still there, and start_lsn the row's min_lsn. The driver's warning still logs
the line.
- State: rows written before keep the text (ADR 0021
amendment 12);
events.switch_ofreads either as the same payload, and the queries in the schema changes guide and the SQL script readcoalesce(get_json_object(detail, '$.text'), detail). Facts migration 15 rewrites thedetailcomment only. - Tests:
tests/test_source_fake.py(the event file's JSON, with and without a column the newer instance does not capture),tests/test_delta_sink.py(the facts row throughto_delta),tests/test_payloads.py(switch_ofon both forms) andtests/integration(against SQL Server 2022); lab check t9 reads it withswitch_of.