Skip to content

Path templates

Every sheet declares a path template that determines where each record’s TOML file lives in the data tree.

For the full spec see specs/behaviors/path-templates.md.

Syntax

Form Example Meaning
Literal users/by-domain/ Static path text
Field reference ${{ slug }} The value of record.slug
Date bucket ${{ publishedAt: YYYY/MM/DD }} UTC date partitions of the field — one directory level per format part
Expression ${{ slug.toLowerCase() }} Arbitrary JS expression with record fields in scope
Recursive ${{ path/** }} Field’s value may contain / — for nested-path fields
Prefix/suffix user-${{ id }}.draft Literal text attached to an expression
Multi-variable ${{ year }}/${{ status }}--${{ id }} Multiple expressions in one segment

Examples

# Simple unique-field key
path = "${{ slug }}"

# Composite key (two-level directory)
path = "${{ domain }}/${{ username }}"

# Date-bucketed (2026/03/09/my-post.toml)
path = "${{ publishedAt: YYYY/MM/DD }}/${{ slug }}"

# Nested path field
path = "${{ contentPath/** }}"

Date buckets

A date-bucket reference partitions records by a date field — by day, month, year, or ISO week:

path = "${{ publishedAt: YYYY/MM/DD }}/${{ slug }}"   # 2026/03/09/my-post.toml
path = "${{ publishedAt: YYYY/MM }}/${{ slug }}"      # 2026/03/my-post.toml
path = "${{ publishedAt: YYYY/WW }}/${{ slug }}"      # 2026/11/my-post.toml (ISO week)
path = "${{ day: YYYY/MM/DD }}"                       # daily rollup: the bucket IS the key

The format is a closed set — YYYY, YYYY/MM, YYYY/MM/DD, YYYY/WW; anything else throws ConfigError(config_invalid) when the sheet is opened. Fancier partitioning falls back to the expression form.

Semantics worth knowing:

  • UTC always. Buckets never consult the host timezone, so the same record produces the same path on every machine. (The expression spelling getFullYear() doesn’t have this guarantee — it renders host-local dates.)
  • YYYY/WW is ISO-8601 week numbering, and its year part is the ISO week-based year: 2027-01-01 belongs to ISO week 2026-W53 and renders as 2026/53.
  • MM, DD, WW are always two digits — partitions sort correctly.
  • The field may hold a datetime/date value or an ISO 8601 string. One bucket token expands to multiple real directory levels, and must stand alone between slashes.
  • Queries that supply the bucketed field (e.g. posts.queryAll({ publishedAt })) descend the exact partition instead of scanning every record; without it, the partition levels are walked like any other unconstrained segment.

How queries use the template

When a query specifies fields the template uses, gitsheets prunes the tree walk to only matching subtrees instead of reading every record.

// Walks only the `af.mil/` subtree
const found = await users.queryAll({ domain: 'af.mil' });

A query that doesn’t supply path-template fields walks every record — still O(records), but more I/O than the pruned form. Equality predicates on path-template fields are the fast path; function-valued filters (e.g., { slug: (v) => v.startsWith('jane') }) are opaque to pruning.

Range queries on date buckets

A range filter entry on a date-bucketed field prunes the walk to the bounded span of partitions instead of visiting every year, month, or day:

// template '${{ publishedAt: YYYY/MM/DD }}/${{ slug }}'
// walks only 2025/11/20 … 2026/02/03 — every other partition is skipped
const q1 = await posts.queryAll({
  publishedAt: { $gte: '2025-11-20', $lt: new Date('2026-02-03T00:00:00Z') },
});

// one-sided ranges work too
const recent = await posts.queryAll({ publishedAt: { $gt: '2026-01-01' } });

The four bounds are $gt, $gte, $lt, $lte in any combination. Bounds may be Dates or ISO 8601 strings (date-only, or a datetime with or without an offset — the same shapes the bucket itself accepts). Pruning is inclusive at the date level on both sides — the walk still visits the bound’s own day — and the record-level comparison then applies the exact $gt / $lt cutoff, so results are identical to a full scan, just cheaper. A range on a plain ${{ field }} component doesn’t prune (unpadded values don’t order like their directory names) but still filters record-level; $in / $or are not supported.

Template fields must be schema-required

A record the template can’t render against can’t be stored: every upsert of it throws path_render_failed and aborts the transaction. So when a sheet declares a persisted schema ([gitsheet.schema], declared contracts, or both), gitsheets checks the template against it at sheet open: every static field the template names — ${{ field }}, ${{ field/** }}, or the base field of a date bucket — must be in a top-level required list of the local schema or of a declared contract. Otherwise the open throws ConfigError(config_invalid) naming the field. Expression components (${{ slug || legacyId }}) are exempt.

There is no exemption list, by principle: a record contains every field needed to render its path (spec). A field your service used to splice in before the upsert (a tenant key, a parent’s slug) belongs on the record and in its schema as required — that handle is how the record references its parent. The 2.8.0 [gitsheet].injectedFields key was removed; a config still carrying it fails to open with an error naming the key.

Sheets with no schema and no contracts are not checked.

Invalid characters

The rendered path is rejected if it contains Windows-disallowed characters (< > : " | ? * or control codes) — those throw PathTemplateError(path_invalid_chars). A non-recursive component producing a value with / is also rejected for the same reason.

If you want slugification, do it in the template:

path = "${{ name.toLowerCase().replace(/[^a-z0-9]+/g, '-') }}"