Changelog
All notable changes to Streamform are recorded here. The format follows Keep a Changelog and the project uses Semantic Versioning; until 1.0, a minor version may contain breaking changes, and each is marked Breaking in its entry.
Each release section is copied verbatim into the GitHub release notes by the release workflow, so write entries for the people who will run the release, not for the people who wrote it.
Unreleased
Added
- Event time on Flink (DEC-009 decision 6): a source with
event_timegetsWATERMARK FOR <column> AS <column> - INTERVAL '<delay>' SECONDin itsCREATE TABLEand, for JSON,'json.timestamp-format.standard' = 'ISO-8601'; withlate_events: dropthe table is created as<source>_rawand the name models read becomes a view that keeps only rows at or afterCURRENT_WATERMARK; a model’sretentionbecomes aSTATE_TTLhint on its aggregate, andbuildandapplywarn that on Flink it is enforced in processing time — approximate, and wrong under replay. JSON timestamps on Kafka are read in Flink’s ISO-8601 form,yyyy-MM-ddTHH:mm:ss[.SSS]without a zone. The integration run gains a differential test over an event-time source with in-order events; lateness and expiry are not asserted there. - Event time runs in the simulator (DEC-009 decisions 3 and 5): a source with
event_timekeeps a watermark — the largest event time seen minus itswatermarkdelay, never moving backwards, or a fixture’s- watermark: <timestamp>item, whichever is later. An event behind the watermark when it is delivered is late: withlate_events: dropit is dropped and counted, withkeepit is delivered as if on time. A model’sretentionexpires a key’s state once the watermark reaches its last event time plus the retention — silently, so the next event for the key starts over as aninsertand an upsert sink keeps the stale row until then.streamform testprintsN late events dropped, and the fixture’slate:list must name every dropped event, exactly and in delivery order: a dropped event that is not listed fails the fixture with a message saying how to acknowledge it, as does a listed event that was delivered.events processedcounts delivered events.test --jsongainslate_droppedandlaterows per fixture (defaulted fields,report_versionstays 1). - Event time is declared and carried: a source in
sources.ymlmay name itsevent_timecolumn (atimestamp), itswatermark(bounded out-of-orderness, a duration such as5m, default0s), andlate_events(droporkeep, defaultdrop); a model may declareretention(a duration, enforced in event time by the simulator; see the entry above).explainprintstime=processingortime=event(<column>) watermark=… late=…on every Source andtime attribute=<column>on every node that carries one;inspect-planprints the same in the Source’s definition and atimefield, which is omitted when there is none, so a plan without event time is byte-identical to one written by v0.4.0 and every recorded digest stands. A model’sretentionappears in its state fingerprint’sretention=(which has saidunboundedsince Phase 1) and therefore moves its state digest and only that digest.checkrejects anevent_timethat is not atimestampcolumn, awatermarkorlate_eventswithoutevent_time, and aretentionon a model with no event-time source upstream. Fixtures accept RFC 3339 timestamps (2026-08-30T10:00:00Z) for timestamp columns beside the integer form,- watermark: <timestamp>items ingiven, and alate:list (DEC-009). - Every release publishes
streamform-examples.tar.gzbeside the binaries, covered bychecksums.txt: the example projects, and the local Kafka and Flink environment asflink-local/. The source repository is private, so this is how the documented first project and the Flink guide are reproduced from public artifacts; the README, the install page, and the guides download it instead of cloning. - The formats are published:
spec/in the repository holds, for every document the binary reads or writes, its JSON Schema (generated from the binary withmise run spec; CI fails when it drifts), real examples produced from the example projects, and a page of prose; the whole directory is mirrored toglyf-data/streamform-specon every release.docs/protocol.mdstates the rules every document and every--jsonfollow: a version field first, newer versions and unknown fields refused, exit codes0/1/2, stdout holds only the document, values rendered after typing. streamform check --json,streamform test --json, andstreamform graph --jsonprint one versioned document each (check_version,report_version,graph_version, all1) and nothing else on stdout; exit codes are unchanged. The test report carries every comparison row the text output shows, with values on both sides rendered after typing against the model’s schema — decimals as strings, so nothing is lost to a float, timestamps as RFC 3339 — and an update’s before-image.check --jsonprints a document even when the project fails to load (loaded: false, exit 2);graph --jsonprints the document with itscycleson a reference cycle and exits 1, where the text form refuses.streamform schema <document>prints the JSON Schema (draft 2020-12) of a document this build reads or writes —project,sources,fixture,plan-manifest,applied-record,diff,check-report,test-report,graph— generated from the same types the binary uses, so the schema cannot disagree with the binary;streamform schema --listnames every document with its version and where it appears.--version Nis refused when this build speaks another version. A project file can name its schema for editor completion with# yaml-language-server: $schema=<file>.
Fixed
- Retention counts from the latest event time a key has seen. The simulator counted from whichever event arrived last, so an out-of-order event (inside the watermark delay, or kept by
late_events: keep) could bring a key’s expiry forward and drop state that a more recent event had just touched. A write now extends a key’s life or leaves it alone, as it does under Flink’s state TTL. - A fixture event with no value in the event-time column fails with
event N has no `<column>`; source `<source>` runs on event time, so every event needs one, where it used to sayplan cannot be simulated. Messages for constructs the simulator does not run sayis not supported yet, notin v0.1. - JSON sinks on Flink write decimals as plain numbers. Flink’s JSON format strips trailing zeros and falls back to scientific notation by default, so a total of 10 reached Kafka as
1E+1, 100 as1E+2, and 0.000000027 as2.7E-8. Generated sink tables now set'json.encode.decimal-as-plain-number' = 'true'(forupsert-kafka, on both the key and the value format), and the same values arrive as10.000000000,100.000000000, and0.000000027, at the column’s scale. Anyone parsing a sink topic as text sees different bytes for the same number; a JSON parser sees the same value.buildoutput changes for every project with a JSON sink.
Changed
- The documentation says what exists: Homebrew, signed artifacts, a container image, and further targets are planned and not available, as are the testing library, the Flink runner image, and the GitHub Action; the only command that opens a network connection is
apply, to the SQL Gateway it is given. examples/order-pipelinedeclares event time:ordersgainsordered_at: timestamp,event_time: ordered_at, andwatermark: 5m; every fixture event carries a timestamp, andtests/late_order.ymlshows a late order dropped and acknowledged.docs/event-time.md(new) walks through event time, watermarks, late events, and retention with three runnable fixtures, and what Flink does with each; the site gains a Time concept page and the testing guide a late-events section.- Breaking (terms): Streamform is a closed-source binary, free to use locally and in production with no account and no limit;
LICENSEis now the terms of use rather than the MIT licence (no copy of the MIT-licensed tree was ever distributed). The file formats, the testing library, the Flink runner image, and the GitHub Action are open source under Apache-2.0 in their own repositories. Releases are published toglyf-data/streamform-releases;cargo install --gitis no longer an install path.
0.4.0 - 2026-08-27
Streamform now says what an upgrade costs before it is deployed: streamform plan compares the running plan with the desired one and classifies every change (SAFE, COMPATIBLE, STATE MIGRATION REQUIRED, BACKFILL REQUIRED, STATE INCOMPATIBLE), streamform apply records what it applied, and streamform.yml can say that a model was renamed so the diff compares the same node.
Added
streamform plan [--target NAME] [--against FILE] [--json]compares the running plan with the desired plan and classifies every change asSAFE,COMPATIBLE,STATE MIGRATION REQUIRED,BACKFILL REQUIRED, orSTATE INCOMPATIBLE, most severe first, with the fields that differ and a reason per node. A keyed node whose upstream definition changed, even in another model, is raised toBACKFILL REQUIRED, since its state was accumulated under the previous definition. Exit0when the application can continue from its existing state,1otherwise,2when the comparison could not run. Documented indocs/plan-diff.md.streamform applywrites the plan it applied, with the time, version, backend, and job ids, to.streamform/applied/<target>.json, and prints the verdict against that record before applying again. It does not refuse on any verdict yet: the Flink backend starts a new job with empty state on every apply, and refusing waits for job lifecycle.renamed_from: <model>andnodes: { <kind>.<ordinal>: { was: <id> } }on a model instreamform.ymltellplanwhich running node a desired node was, so a renamed model or a shifted ordinal compares as the same node.streamform checkrejects malformed or contradictory hints and a hint for a node the plan does not have;planwarns about a hint that names no running node.
0.3.0 - 2026-08-26
Streamform is the project’s name from this release on, and every stateful node now has an identity that survives an upgrade: streamform inspect-plan prints stable node ids, SHA-256 state and schema digests, and changelog contracts, streamform build writes them to build/plan.json, and a documentation site lives under site/. The release artifacts are the first named streamform-<target>.tar.gz.
Added
streamform inspect-plan [MODEL] [--json]prints the stable identity of the plan: every node’s id, definition, output schema with its digest, changelog contract, and, for a keyed operator, its key, accumulators, retention, and state digest. With--jsonit prints the plan manifest, a versioned document (manifest_version: 1) holding the same for the whole application plus an application digest. Documented indocs/plan-identity.md.streamform buildwrites the plan manifest tobuild/plan.jsonnext to the generated application, so every build carries the identity it was built from.- A documentation site under
site/(mise run docs,mise run docs-serve): getting started, concepts, guides, and reference, with the founding documents included fromdocs/. A landing page lives atsite/landing/index.html. TheDocsworkflow builds both on every change; the site is run locally, nothing deploys it.
Changed
- Breaking: the project is renamed to Streamform. The binary is
streamform, the crates arestreamform-*, the project manifest file isstreamform.yml, and the release artifacts arestreamform-<target>.tar.gz. Releases v0.1.0 and v0.2.0 shipped under the previous name; their entries below are written with the current names. - Breaking: an aggregate column without an alias is now named by Streamform (
sum(amount),count(*)) rather than by the SQL planner (sum(orders.amount)), and an aggregate column keeps its accumulator type through a projection (decimal(38,2)for aSUMoverdecimal(10,2), where the planner saiddecimal(20,2)). Generated Flink SQL changes accordingly for unaliased aggregates; fixtures that name such a column must use the new name. Aliased columns are unaffected. streamform explainprints adigest=line under a keyed operator’s fingerprint: the first 12 characters of the SHA-256 of the fingerprint text.- Breaking:
streamform explain --jsonprints the model’s slice of the plan manifest (manifest_version,model,nodes) instead of the previous hand-written shape; every field of the plan is now present, andfingerprint_digestisstate.digest.
Fixed
- State fingerprint digests were computed with the standard library’s hasher, whose output may change between Rust releases. They are now SHA-256 over the fingerprint’s canonical text, so a toolchain upgrade cannot mark existing state incompatible.
0.2.0 - 2026-08-25
Streamform understands an application, not a statement, and runs it on Flink. ref() chains models into a dependency graph; streamform graph draws it; streamform build --backend flink turns the plan into Flink SQL and streamform apply submits it through the SQL Gateway; mise run integration proves on real Kafka and Flink 2.0.2 that the simulator and Flink agree. Coverage fixtures now run every aggregate, operator, and column type through SQL.
Added
ref('<model>')in model SQL reads another model of the same project. Models form a dependency graph;streamform checkreports an unknownref(), a reference cycle, and a name used by both a source and a model.streamform explain <model>shows the upstream model asfrom=<model>on the first operator, andstreamform explainwith no model prints every model in dependency order.- A fixture may target a model that reads other models:
givenevents go to the source at the top of the chain andexpectis asserted on the target model’s output, after every model in between. - A model’s Sink shows its declared materialization and, when a
sink:block is configured, the connector and target. streamform build --backend flink [--target NAME] [--stdout]generates the application as Flink SQL: aCREATE TABLEper source, aCREATE TEMPORARY VIEWper model, aCREATE TABLE sink_<model>per declared sink (upsert-kafkawith a primary key forupsertmodels), and oneEXECUTE STATEMENT SET. No Java or Python is generated. Documented indocs/flink-backend.md.streamform apply [--target NAME]builds the application and submits it through the target’s Flink SQL Gateway REST API, statement by statement, printing the job id; a rejected statement stops the apply with Flink’s reason and exit code 1.integration/docker-compose.ymlruns Kafka and Flink 2.0.2 with the Kafka connector locally;mise run integrationapplies the examples to it and checks that the sink topics carry exactly what the simulator’s changelog implies, tombstones included.targets:instreamform.ymlnames where an application is built for and applied to:backend,flink.gateway,kafka.bootstrap_servers,kafka.startup.streamform checkreports a Flink target missing its sections.streamform graphprints the model dependency graph as an indented tree;streamform graph --format dotemits Graphviz.examples/order-pipeline: the three-model example,orders → clean_orders → customer_metrics → big_customers, whose last fixture asserts an insert, an update, and a delete from one filter over an upsert model.
Changed
- Breaking for callers of the Rust API:
streamform_sql::compileandcompile_allare replaced bycompile_application, which returns one plan for the whole project;streamform_sim::runtakes the model to run. The CLI is unchanged. GROUP BYover a model that emits updates is refused at planning (“aggregation over an updating stream”), since aggregating updates needs retractions that are not supported yet.- Unsupported constructs are reported as “not supported yet” rather than “not supported in v0.1”.
- An
upsertmodel’s declaredkeymust match the key its plan produces:streamform checknow reports a key that disagrees with theGROUP BY(previously accepted silently), and a global aggregate is declared with no key (previously impossible).
Fixed
COUNT(*)failed to plan (“the expression *”) in every release so far; nothing had exercised it through SQL. A coverage fixture project now runs every aggregate, operator, and column type through SQL.
0.1.0 - 2026-08-24
The first release: deterministic testing and inspection for streaming SQL semantics. No Flink, Kafka, dbt, Python, windows, watermarks, joins, ref(), or deployment yet.
Added
streamform checkloads and validates a project —streamform.yml,sources.yml,models/*.sql,tests/*.yml— compiles every model, and reports every problem in one run withdid you meansuggestions.streamform explain <model>prints the model’s streaming plan: operators, boundedness, changelog mode, state requirement, key, and a state fingerprint naming each accumulator and its type.--jsonfor machine consumption.streamform test [--model NAME]runs every fixture through a deterministic in-process simulator and asserts the exact changelog, record by record. PrintsPASS/FAILwith event and record counts, a side-by-side diff on failure, and exits1when any fixture fails.- The Streaming IR (
streamform-ir): source, filter, projection, keyed aggregation, and sink operators; append and upsert changelog modes; stable node identity<model>::<kind>::<ordinal>; state fingerprints rendered through Streamform-owned type names so a dependency upgrade cannot move them. - The SQL frontend (
streamform-sql): DataFusion plans a model’sSELECT, and lowering produces the IR. Supported: column references, literals, comparison and arithmetic operators,AND/OR/NOT,IS [NOT] NULL,CAST,WHERE,GROUP BYover columns,HAVING, and the aggregatessum,count,min,max. Anything else fails naming the construct.ORDER BYover an unbounded source is rejected as meaningless. - The simulator (
streamform-sim): single-threaded, in-memory, no wall clock; operators defined over changelogs so a filter after an aggregate emits inserts and deletes at the predicate boundary; exact fixed-point decimals; checked arithmetic that reports overflow rather than wrapping. - Project format:
source('<name>')in SQL; per-modelmaterialized: append | upsertwithkey; source column typesint32,int64,float64,decimal,decimal(precision,scale),string,boolean,timestamp; fixtures withgivenevents andexpectrecords carryingop: insert | update | delete. Documented indocs/project-format.md. - Release builds for macOS (Intel and Apple silicon) and Linux x86_64 with
checksums.txt, andcargo install --gitfrom the tag.