Skip to content

Data Layout

SAL separates source files from generated data. Your Git repository contains RDF source files and project code; .sal/data contains generated output.

After sal init, a project has:

.sal/
data/
sparql/

sparql/ is for the project’s SPARQL queries. It is source rather than generated data, so it sits in the project root and is not gitignored. Every CONSTRUCT query in it, a file ending in .rq or .sparql, is run by sal build, which commits the triples it constructs with the rest of the build.

.sal/constructed holds what each of those queries constructed, one Turtle file per query named after it, written by sal build and by sal construct. It is gitignored, wiped before every run, and removed by sal clean --wipe. It is a copy for reading: the constructed triples themselves are in the table.

After sal build, .sal/data contains the built data product. For the main triples table, SAL writes an Apache Iceberg table under the project namespace:

.sal/
config.jsonld
data/
blobs/
<sha256-of-a-vocabulary-document>
<sha256-of-a-file-a-module-task-produced>
prov.jsonld
<sha256-of-a-directory-a-module-task-produced>/
stac/
catalog.json
triples/
collection.json
<git-project-name>/
triples/
data/
metadata/

The metadata directory contains Iceberg table metadata, manifest lists, manifests, and version-hint.text. The data directory contains the table’s data files.

The files named by a hash under .sal/data/blobs are the vocabulary documents the project pins, described in Pinned vocabularies below, and the files SAL module tasks produce, which sal build copies out of the module’s container under the SHA-256 digest of their contents. sal serve serves both over HTTP at /blobs/{sha256}. A directory a task produces, a Zarr store or STAC catalog for instance, is copied with its files verbatim under {digest}/, the SHA-256 of its contents, so that it can be read in place from disk, from /blobs/{digest}/, or from a bucket the data product is uploaded to, and /blobs/{digest} serves it whole as a zip archive. Everything under .sal/data/blobs other than prov.jsonld is named by a hash. prov.jsonld is the log of every file and directory tasks have copied there, each under its digest with the module that produced it; a run adds to it and never removes from it. See Files a task produces.

.sal/data/stac is the STAC catalog the build writes last, describing the table it just committed; see STAC catalog below. sal serve serves it at /stac/catalog.json.

SAL builds RDF into rows representing triples, with the object split across one column per kind of term so that each can be compared and read natively:

  • subject
  • predicate
  • object_string: a string or any other literal without a typed column, or a blank node with its _: prefix
  • object_iri: an IRI object
  • object_geometry: a GeoSPARQL wktLiteral, stored as an Iceberg geometry
  • object_byte: an xsd:byte literal, stored as an INTEGER (Iceberg has no 8-bit type)
  • object_integer: an integer-valued XSD literal (xsd:integer, xsd:int, xsd:long, …), stored as a BIGINT
  • object_float: an xsd:float, xsd:double, or xsd:decimal literal, stored as a DOUBLE
  • object_time: an xsd:dateTime literal with an explicit timezone, stored as a TIMESTAMP normalized to UTC
  • object_type: the datatype IRI the literal object was written with; NULL for an IRI or blank node object
  • object_language: the language tag of an rdf:langString literal object, such as en for "hello"@en; NULL for every other object
  • triple_hash

Every row is a statement the project makes: what its source files assert, what it imports with owl:imports, what its modules produced, and the provenance of its pins. A vocabulary the project only validates against contributes no rows, so a SPARQL query reasons over exactly what the project asserted or imported; see Reasoning over the graph.

Exactly one of the object value columns is set on each row, chosen by the literal’s declared datatype rather than by what its text happens to parse as. A typed literal whose lexical form does not fit its column — an out-of-range xsd:byte, a zoneless or sub-microsecond xsd:dateTime — is stored in object_string, and any datatype without a typed column (booleans, dates, custom types) lands there too. object_type keeps the exact datatype in every case, and object_language keeps the language tag of a language-tagged string, so exporting the table back to RDF (sal export) reproduces the original typed or tagged literal even when the value was stored as a string. The geometry column is what makes the table an Iceberg format version 3 table.

The hash identifies a triple from its subject, predicate, object, and the object’s datatype and language tag, so "chat"@en and "chat"@fr are two rows. SAL uses this identity to avoid writing duplicate triples when the table already contains the same data.

Before writing output, SAL validates RDF source files. Source data can be Turtle or JSON-LD.

Validation checks that terms used through prefixes are defined by the provided vocabularies. For example, a misspelled term such as schema:nameee should fail validation rather than becoming a built triple.

Use sal validate to run these checks without writing a new build.

.sal/config.jsonld is a single JSON-LD document with one @graph that condenses everything a project records about itself and the vocabularies it resolves against: the project’s own ontology (what sal import writes) and the pinned vocabulary versions (what sal build writes). Both used to be separate files, .sal/ontology.jsonld and .sal/ns-prefix-versions.jsonld; they are now nodes in the same @graph, so a project has a single file describing everything it resolves and materializes against.

The first node in the graph, if the project has run sal import at least once, is the project’s own ontology:

{
"@id": ".",
"@type": "owl:Ontology",
"dc:title": "my-project",
"owl:imports": [{ "@id": "http://www.w3.org/2002/07/owl#" }],
"rdfs:comment": "Represents the ontology for the my-project project overall and all the vocabularies that are directly materialized into the graph, not just referenced."
}

dc:title defaults to the Git project name and --title overrides it; rdfs:comment is always generated. Ontologies listed with owl:imports are merged at the version pinned for them as the project’s own statements. Pinning a vocabulary does not import it: a vocabulary the project validates against but does not import has no statements in the triples table, only the provenance node recording which version was pinned.

Vocabularies are resolved per project rather than from a shared cache on the machine. sal build records the exact version of every vocabulary the project resolves against as the rest of .sal/config.jsonld’s @graph, one node per vocabulary, distinguished from the project ontology node by carrying owl:versionIRI:

{
"@context": {
"dc": "http://purl.org/dc/elements/1.1/",
"owl": "http://www.w3.org/2002/07/owl#",
"dcterms": "http://purl.org/dc/terms/",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#"
},
"@graph": [
{
"@id": ".",
"@type": "owl:Ontology",
"dc:title": "my-project",
"rdfs:comment": "Represents the ontology for the my-project project overall and all the vocabularies that are directly materialized into the graph, not just referenced."
},
{
"@id": "https://schema.org/",
"@type": "owl:Ontology",
"rdfs:label": "https://schema.org/",
"owl:versionIRI": { "@id": "urn:sha256:c3a1…" },
"dcterms:format": "application/ld+json",
"dcterms:modified": { "@value": "2026-08-11T17:21:12Z", "@type": "xsd:dateTime" },
"rdfs:comment": "Represents the cached version of the https://schema.org/ vocabularies as of 2026-08-11T17:21:12Z"
}
]
}

The document behind each pin is stored under .sal/data/blobs named by that same SHA-256. Each pinned node carries the vocabulary’s namespace IRI as its rdfs:label, the human readable name it is known by, and the same node is committed into the triples table as provenance on every build. config.jsonld belongs in Git; the documents it names do not, since sal init adds .sal/data to .gitignore and they travel with the data product instead.

On each build, SAL:

  1. Collects every prefix the project’s source files declare, building any SAL module it references so the module’s own ontology counts as one of them.
  2. Resolves each prefix that is not already pinned, hashes the document it received, writes it under .sal/data/blobs, and records the mapping.
  3. Resolves every prefix that is already pinned from the stored document rather than from the network.

Validation therefore checks terms against the exact version of a vocabulary the project pinned, not against whatever its publisher is serving today. A prefix that is declared but never used is pinned too, so adding a term from it later does not silently change which version the project validates against. A pin whose document is missing locally — as in a fresh clone that has not pulled the data product — is fetched again rather than failing the build.

Use --no-cache on sal build to resolve every prefix from its source again and re-pin it, which is how a project moves to newer vocabulary versions.

Every pinned vocabulary node sal build writes to .sal/config.jsonld is also written into the triples table itself, as an owl:Ontology node carrying the same owl:versionIRI, dcterms:format, dcterms:modified, and rdfs:comment the config file records. This is provenance: it says which exact version of each vocabulary the data next to it was validated against, and it is queryable like any other triple, through sal query --sparql, sal serve’s /sparql endpoint, or the Ontology versions sample in the SPARQL tab of sal serve --with-ui.

/sparql translates SPARQL to DuckDB SQL and only understands basic triple patterns, FILTER comparisons, DISTINCT, and LIMIT — no OPTIONAL and no ORDER BY — so the query stays inside that subset (see SPARQL Query Engine):

PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX owl: <http://www.w3.org/2002/07/owl#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?ontology ?versionIRI ?format ?modified
WHERE {
?ontology rdf:type owl:Ontology .
?ontology owl:versionIRI ?versionIRI .
?ontology dcterms:format ?format .
?ontology dcterms:modified ?modified .
}
ontology versionIRI format modified
http://www.opengis.net/ont/geosparql# urn:sha256:6a55c78c2b38c077a31075363ef5e908fa683c61eb059a0fc9828aeb… application/rdf+xml 2026-08-11T17:21:12Z
https://schema.org/ urn:sha256:c3a1e7c9c4b6f6b1a3f5e6d7c8b9a0e1f2d3c4b5a6978869e6fa84d… application/ld+json 2026-08-11T17:21:12Z

A vocabulary the project pins but does not import, one it only checks terms against, shows up here the same as an imported one; whether an ontology’s own statements are in the table at all is a separate question from which version it validated against, and only an imported one’s are.

The last thing sal build does is describe the data product as a STAC catalog under .sal/data/stac, so that a STAC client can discover it and an Iceberg reader can open it from what the catalog records. catalog.json is a STAC Catalog named after the git project, listing triples/collection.json as a child, and after it every STAC catalog a SAL module task handed over as a directory (see Files a task produces): a directory under .sal/data/blobs whose catalog.json is a STAC Catalog or Collection is linked as a child by relative path, such as ../blobs/3f9a…/catalog.json, titled by the catalog’s own title or id. The module’s files are left where the copy put them, since their own links name their neighbours by relative path, and the link is relative too, so the data product is one catalog tree on disk, under sal serve, and in a bucket it is uploaded to. A sub-catalog inside a linked catalog is that catalog’s to list and is not linked again; a catalog.json that is not STAC is warned about and skipped. The triples/collection.json collection describes the triples table with three extensions:

  • The Table extension: table:columns names every column of the table with its type, table:row_count is the number of rows, the statements of the pinned vocabularies included since the catalog describes the table itself, and table:primary_geometry is object_geometry.
  • The Projection extension: proj:code is the CRS of the geometry column, OGC:CRS84.
  • The Iceberg extension, whose fields are how a reader opens the table:
{
"iceberg:catalog_type": "static",
"iceberg:authorization_type": "none",
"iceberg:table_id": "<git-project-name>.triples",
"iceberg:metadata_location": "file:///path/to/project/.sal/data/<git-project-name>/triples/metadata/v3.metadata.json",
"iceberg:format_version": 3,
"iceberg:current_snapshot_id": "7847372623449923428",
"iceberg:partition_spec": [
{ "name": "predicate_partition", "transform": "truncate[20]", "source-id": 2, "field-id": 1000 }
]
}

The same metadata.json is the collection’s one asset, iceberg, with the media type application/vnd.apache.iceberg+json the extension gives Iceberg metadata. The catalog is static: there is no catalog server, and the metadata location is a file:// URL on the machine the build ran on, which DuckDB’s iceberg_scan() or PyIceberg’s StaticTable can read directly there.

Everything about the table is read from the table itself once it is written: the columns, partition spec, format version, and metadata location from its metadata, the snapshot ID from its current snapshot, and the row count and spatial extent from a scan of its geometry column. extent.spatial.bbox covers every geometry in the table, or the whole world when it holds none, since STAC requires a collection to state one. extent.temporal.interval runs from the earliest snapshot still in the table to the current one, which is when the data product was built. The collection’s title is the project ontology’s dc:title when sal import has recorded one, and the git project name otherwise, and its provider is the git project owner.

The catalog is regenerated whole on every build, since it names the snapshot and metadata file that build produced, and it sits under .sal/data rather than beside .sal/config.jsonld for the same reason: it is a build artifact, not a source file, and writing it into Git would leave the working tree dirty after every build. sal clean --wipe removes it with the rest of the data product.

sal push preserves .sal/data as an OCI artifact, including the pinned vocabulary documents. The artifact metadata records the source repository and commit, which lets sal clone recreate the matching source tree and data directory.

sal upload deploys the same data to an object store. For object stores, SAL rewrites a staged copy of the Iceberg metadata so table paths point to the remote table root. The local .sal/data directory is not changed by upload.

For examples, see Publishing Data.

Developed byCenter for Geospatial SolutionsCenter for Geospatial Solutions