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/WWis 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 as2026/53.MM,DD,WWare 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, '-') }}"