Path DSL
The S3WORM Path DSL is a mini-language for describing the structure of S3 keys. It defines how entity data is organized inside a bucket -- namespaces, collections, dynamic segments (IDs), and required files.
A path like #org/@customers/(id:uuid)/[profile].json maps to S3 keys like org/customers/a1b2c3d4-e5f6-7890-abcd-ef1234567890/profile.json.
Symbols
The DSL uses four symbols to describe path segments. Each symbol is stripped during resolution, leaving clean S3 keys.
| Symbol | Name | Syntax | Description |
|---|---|---|---|
# | Namespace | #name | A fixed organizational prefix. Groups related collections. |
@ | Collection | @name | A collection of entities. The directory that holds entity folders. |
() | Dynamic Segment | (name:type) | A variable segment, typically an entity ID. Replaced with actual values at runtime. |
[] | Required File | [name].ext | A required document file within an entity folder. |
Namespace: #
Namespaces are fixed prefixes that organize your bucket into logical sections:
#org/@customers/... -> org/customers/...
#data/@logs/... -> data/logs/...
#billing/@invoices/... -> billing/invoices/...
Multiple namespaces can be nested:
#app/#v2/@users/... -> app/v2/users/...
Collection: @
Collections mark directories that contain multiple entities:
#org/@customers/(id:uuid)/[profile].json
^^^^^^^^^
This is the collection directory.
S3 key prefix: org/customers/
Dynamic Segment: ()
Dynamic segments represent variable path parts -- usually entity IDs. The syntax is (name:type) where name is a label and type is a dynamic type:
#org/@customers/(id:uuid)/[profile].json
^^^^^^^^^
Replaced with the actual entity ID at runtime.
When resolving the base path for a collection, the StorageResolver stops at the first dynamic segment. Everything before it becomes the S3 prefix for listing entities.
Required File: []
Required files specify the document filename for an entity:
#org/@customers/(id:uuid)/[profile].json
^^^^^^^^^^^^^^
The entity document is named "profile.json"
The brackets are stripped during resolution: [profile].json becomes profile.json.
Dynamic Types
Dynamic segments have a type that defines the format of valid values. Each type has an associated regex pattern for validation.
| Type | Pattern | Example Values |
|---|---|---|
uuid | ^[0-9a-fA-F-]{36}$ | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
evm | ^0x[a-fA-F0-9]{40}$ | 0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18 |
sol | ^[1-9A-HJ-NP-Za-km-z]{32,44}$ | DRpbCBMxVnDK7maPMoaH6... |
Custom Dynamic Types
Define additional types in the schema's dynamicTypes section:
{
"dynamicTypes": {
"uuid": { "regex": "^[0-9a-fA-F-]{36}$" },
"evm": { "regex": "^0x[a-fA-F0-9]{40}$" },
"sol": { "regex": "^[1-9A-HJ-NP-Za-km-z]{32,44}$" },
"slug": { "regex": "^[a-z0-9-]+$" },
"date": { "regex": "^\\d{4}-\\d{2}-\\d{2}$" }
}
}
These types are used in path validation and codegen. The CLI worm lint command checks that dynamic segments in paths reference defined types.
Operators
Operators control how dynamic segment values are generated when creating new entities.
| Operator | Strategy | Description |
|---|---|---|
auto | UUID generation | Generates a random UUID for new entities. Default for uuid type. |
seq | Sequential increment | Assigns a monotonically increasing integer. Configured with startAt and step. |
Operator Definitions
{
"operators": {
"auto": { "strategy": "uuid" },
"seq": { "strategy": "increment", "startAt": 1, "step": 1 }
}
}
When a model's idType is "uuid", the auto operator generates IDs automatically on save. With seq, the repository reads the current maximum sequence number and increments.
Example Paths
Basic Entity Collection
Path: #org/@customers/(id:uuid)/[profile].json
Resolves: org/customers/{uuid}/profile.json
Example: org/customers/a1b2c3d4-e5f6-7890-abcd-ef1234567890/profile.json
#org-- namespace prefix, becomesorg/@customers-- collection directory, becomescustomers/(id:uuid)-- entity ID slot, replaced with a UUID[profile].json-- document filename, becomesprofile.json
Multiple Files Per Entity
An entity can have multiple required files:
Path 1: #org/@customers/(id:uuid)/[profile].json
Path 2: #org/@customers/(id:uuid)/[invoices].json
Both paths share the same collection and entity ID. The entity folder contains two documents:
org/customers/a1b2c3d4-.../
profile.json
invoices.json
Singleton (No Dynamic Segment)
Path: #org/@settings/[config].json
Resolves: org/settings/config.json
No dynamic segment means there is exactly one document at this path. In the schema, mark this model as "singleton": true.
EVM Address Collection
Path: #wallets/@accounts/(addr:evm)/[balance].json
Resolves: wallets/accounts/{evm-address}/balance.json
Example: wallets/accounts/0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18/balance.json
Nested Collections
Path: #org/@departments/(deptId:uuid)/@teams/(teamId:uuid)/[roster].json
This defines a two-level hierarchy. The storage resolver stops at the first dynamic segment for the base path:
Base path: org/departments/
Entity path: org/departments/{deptId}/teams/{teamId}/roster.json
Date-Partitioned Logs
Path: #data/@logs/(date:date)/@entries/(id:uuid)/[event].json
With a custom date type (^\d{4}-\d{2}-\d{2}$):
data/logs/2026-02-24/entries/abc-123/event.json
How Paths Map to S3 Keys
The StorageResolver class transforms path DSL into actual S3 keys:
Base Path Resolution
The base path is everything up to (but not including) the first dynamic segment, with symbols stripped:
| DSL Path | Base Path |
|---|---|
#org/@customers/(id:uuid) | org/customers |
#org/@settings | org/settings |
#data/@logs/(date:iso)/@entries | data/logs |
#billing/@invoices/(id:uuid) | billing/invoices |
Entity Key Resolution
For a specific entity, the ID is inserted and the filename is appended:
| Model Definition | Entity ID | Resolved S3 Key |
|---|---|---|
path: #org/@customers/(id:uuid), file: [profile].json | abc-123 | org/customers/abc-123/profile.json |
path: #org/@settings, file: [config].json, singleton | N/A | org/settings/config.json |
path: #billing/@invoices/(id:uuid), file: [data].json | inv-001 | billing/invoices/inv-001/data.json |
Collection Prefix
For listing all entities in a collection, the resolver returns the base path with a trailing slash:
| Model | Collection Prefix |
|---|---|
| Customer | org/customers/ |
| Invoice | billing/invoices/ |
| OrgSettings | org/settings/ |
System Directory Resolution
The storage layout ("inline", "collection", or "root") determines where oplog, snapshot, and trash directories live relative to the entity:
Layout: "root" (default)
.oplog/org/customers/abc-123/ -- oplog entries
.snapshots/org/customers/abc-123/ -- snapshots
.worm.trash/org/customers/abc-123/ -- soft-deleted data
Layout: "collection"
org/customers/.oplog/abc-123/
org/customers/.snapshots/abc-123/
org/customers/.worm.trash/abc-123/
Layout: "inline"
org/customers/abc-123/.oplog/
org/customers/abc-123/.snapshots/
org/customers/abc-123/.worm.trash/
Full Schema Example
Putting it all together in a .worm/schema.json:
{
"schemaVersion": "1.0",
"sourceOfTruth": "local",
"symbols": {
"namespace": "#",
"collection": "@",
"dynamic": "()",
"requiredFile": "[]"
},
"dynamicTypes": {
"uuid": { "regex": "^[0-9a-fA-F-]{36}$" },
"evm": { "regex": "^0x[a-fA-F0-9]{40}$" }
},
"operators": {
"auto": { "strategy": "uuid" },
"seq": { "strategy": "increment", "startAt": 1, "step": 1 }
},
"storage": {
"layout": "root"
},
"paths": [
"#org/@customers/(id:uuid)/[profile].json",
"#org/@customers/(id:uuid)/[invoices].json",
"#org/@invoices/(id:uuid)/[data].json",
"#org/@settings/[config].json"
],
"models": {
"Customer": {
"path": "#org/@customers/(id:uuid)",
"idType": "uuid",
"fields": {
"name": { "type": "string", "required": true },
"email": { "type": "string", "required": true },
"status": { "type": "string", "enum": ["active", "inactive"], "default": "active" }
},
"file": "[profile].json",
"mode": "readwrite",
"oplog": true,
"softDelete": true
},
"Invoice": {
"path": "#org/@invoices/(id:uuid)",
"idType": "uuid",
"fields": {
"customerId": { "type": "string", "required": true, "ref": "Customer" },
"amount": { "type": "number", "required": true },
"status": { "type": "string", "enum": ["draft", "sent", "paid"], "default": "draft" }
},
"file": "[data].json",
"mode": "readwrite"
},
"OrgSettings": {
"path": "#org/@settings",
"singleton": true,
"fields": {
"orgName": { "type": "string", "required": true },
"plan": { "type": "string", "enum": ["free", "pro", "enterprise"] }
},
"file": "[config].json",
"mode": "readwrite"
}
}
}
The bucket structure that results from this schema:
my-bucket/
.worm -- bucket sentinel
.oplog/ -- global oplog (layout: root)
org/customers/
abc-123/
0001.json
0002.json
.worm.trash/ -- global trash
org/customers/
deleted-xyz/
profile.json
.worm.tombstone
org/
customers/
.worm -- collection sentinel
abc-123/
profile.json -- Customer entity
def-456/
profile.json
invoices/
.worm
inv-001/
data.json -- Invoice entity
settings/
.worm
config.json -- OrgSettings singleton