Skip to main content

ConjureDB Schema Language Reference

Comprehensive reference for the ConjureDB Schema DSL (.conjure files). Version: current · Engine: ConjureDB · Build SDK: .NET 9.0.300 · Runtime targets: .NET 8 / .NET Standard 2.1


1 Overview

The ConjureDB Schema Language is the single public authoring contract for defining data models, indexes, queries, mutations, commands, and reusable fragments in .conjure files. The schema compiler generates both metadata and executable C# artifacts from that source of truth.

1.1 Schema-First Contract

AspectContract
DefinitionAll tables, views, materialized views, indexes, queries, mutations, commands, and extern functions live in .conjure files
Code generationGenerated entities, DbContext, repositories/modules, commands, indexes, and operation registration
ToolingCLI, MSBuild AdditionalFiles, source generator, and LSP diagnostics consume the same schema pipeline
Source of truth.conjure; generated C# is compiler output and must not be hand-edited

1.2 Code Generation Pipeline

The schema compilation pipeline processes .conjure files in five deterministic stages:

┌──────────┐ ┌────────────────┐ ┌───────┐ ┌──────┐ ┌──────┐
│ Parse │ -> │ Resolve Imports│ -> │ Merge │ -> │ Bind │ -> │ Emit │
└──────────┘ └────────────────┘ └───────┘ └──────┘ └──────┘
SCH0xxx/1xxx SCH3xxx -- SCH2xxx .g.cs files
  1. Parse — each .conjure file is independently lexed and parsed into an AST.
  2. Resolve Imports — import paths are validated and a deterministic dependency order is built (topological sort).
  3. Merge — declarations from all files are merged into a single SchemaFile; duplicate names produce errors.
  4. Bind — name resolution, type checking, FK/relation validation, index validation, fragment expansion.
  5. Emit — metadata C# generation for entities, enums, custom types, repository interfaces, mutation interfaces, and DbContext.

Errors at any stage abort the pipeline with structured diagnostics printed to stderr.

1.3 Generated Artifacts

Schema DeclarationGenerated FileC# Artifact
table PlayerPlayer.g.cs[MessagePackObject] public record Player
struct table PosPos.g.cs[MessagePackObject] public record struct Pos
view ActiveRowsmetadata onlyVirtual reusable query source, no storage
materialized view PlayerItemRowmetadata + derived runtime stateIn-memory derived relation, no persistence/type id/schema version
enum StatusStatus.g.cspublic enum Status
type PlayerDtoPlayerDto.g.cspublic record PlayerDto
struct type Vec2Vec2.g.cspublic record struct Vec2
query GetX(…) -> Player[]IPlayerQueries.csinterface IPlayerQueries : IRepository<Player>
reactive query Watch(…) -> Player[]IPlayerQueries.csMethod returns ReactiveQuery<Player>
mutation Ban(…)IPlayerMutations.csinterface IPlayerMutations : IRepository<Player>
(all tables)AppDbContext.g.csmetadata-only partial class AppDbContext : DbContext

2 File Structure

2.1 File Format

Schema files use the .conjure extension. They are plain text, UTF-8 encoded. The parser is line-ending agnostic (LF, CRLF, CR all work).

2.2 Comments

Line comments start with // and extend to the end of the line:

// This is a comment
table Player { // inline comment
id : int @id
}

Block comments are not supported.

2.3 Top-Level Declarations

A .conjure file may contain any number of the following top-level declarations, in any order:

import "path/to/other.conjure"
extern type Alias = Qualified.ClrName
enum EnumName { … }
type TypeName { … }
struct type TypeName { … }
module ModuleName { … }
fragment name(params…) = body
view ViewName { … } = pipeline
materialized view ViewName(options…) { … } = pipeline

Modules are an optional organizational unit for most declarations and the required identity boundary for commands. Tables, queries, mutations, reactive queries, enums, types, extern types, and fragments may appear either at the top level or inside modules. Commands must appear inside a module so their canonical identity is Module.Command. When modules are present, ConjureDB emits an I{ModuleName} interface that groups the declarations owned by that module.

2.4 Imports

Import other .conjure files to reference their declarations:

import "common.conjure"
import "../shared/types.conjure"

Resolution rules:

  • Paths are resolved relative to the importing file's directory.
  • Resolved paths are canonicalized to absolute normalized file identities.
  • The import graph must be acyclic — cycles produce diagnostic SCH3003.
  • An import target that is not part of the discovered schema file set produces SCH3002.

Compilation order is import-driven: dependencies are processed before importers.

schema/core/base.conjure
schema/shared/types.conjure // import "../core/base.conjure"
schema/feature/main.conjure // import "../shared/types.conjure"

Resulting compilation order: base.conjuretypes.conjuremain.conjure.

2.5 Module Definitions

Modules group related tables, queries, mutations, commands, and reactive queries into a cohesive unit. Each module generates a C# interface (I{ModuleName}) that serves as the API surface for its operations.

Syntax

module <Name> {
[enum | type | extern type | struct table | table | view | materialized view | query | reactive query | mutation | command | fragment]*
}

Scoping Rules

DeclarationTop-level?Inside module?Notes
tableCore data; modules add generated interface grouping
viewVirtual reusable query source; no indexes or storage
materialized viewDerived in-memory relation with @@index; no persistence
queryOperations on data; modules surface them via I{Module}
reactive querySame as query, with reactive return shapes
mutationSame as query, for mutation contracts
commandDeterministic operation contracts; module name forms identity
enumShared infrastructure, may be global
typeShared projection types
extern typeCLR bridge types, inherently global
fragmentReusable query snippets

All declarations are globally visible regardless of where they are defined — modules are organizational, not namespaces.

Example

// Global shared types
enum Rarity { Common = 0, Uncommon = 1, Rare = 2, Epic = 3, Legendary = 4 }
type PlayerDto { id: int, name: string, level: int }

module Core {
table Player(persistence: local) {
id : int @id
name : string
level : int
score : int
}

query TopPlayers(count: int) -> Player[] =
from Player | sort -score | take @count

mutation BanPlayer(player_id: int) =
update Player | where id == @player_id | set status = 1
}

module LootGeneration {
table LootItem {
id : int @id
name : string
rarity : Rarity
drop_weight : float
}

query RollLoot(seed: long) -> LootItem[] =
from generate(64, @seed) g
| join LootItem i (hash_index(g.Seed, 0L, 6) == i.Id)
| select i.*
}

Generated C#

Each module produces an interface when modules are used:

// module Core → ICore
public interface ICore
{
Player[] TopPlayers(int count);
void BanPlayer(int playerId);
}

// module LootGeneration → ILootGeneration : IDpgQueryHost
// (IDpgQueryHost base when module contains virtual-source queries)
public interface ILootGeneration : IDpgQueryHost
{
LootItem[] RollLoot(long seed);
}

After the emitted C# is added to a project that also runs ConjureDB.CodeGen, the executable DbContext exposes modules via a typed accessor:

context.Module<ICore>().TopPlayers(10);
context.Module<ILootGeneration>().RollLoot(seed);

Interface Base Rules

Module ContentGenerated Base
Has virtual-source queries (from generate(…))IDpgQueryHost
Only table-sourced queriesNo base (standalone)
MixedIDpgQueryHost

Diagnostics

CodeDescription
SCH3001Duplicate declaration across files (generic cross-file duplicate of any kind, including modules)
SCH3003Import cycle detected
SCH3004Duplicate canonical file identity (two files normalize to the same path)

In-module duplicate declarations are reported as SCH2001.


3 Type System

3.1 Primitive Types

Schema TypeC# TypeDescription
intintSigned 32-bit integer
longlongSigned 64-bit integer
floatfloat32-bit IEEE 754 float
doubledouble64-bit IEEE 754 float
decimaldecimal128-bit decimal
stringstringUTF-16 string (reference type)
boolboolBoolean
bytebyteUnsigned 8-bit integer
shortshortSigned 16-bit integer
uintuintUnsigned 32-bit integer
ulongulongUnsigned 64-bit integer
datetimeDateTimeDate and time
timespanTimeSpanTime duration
guidGuid128-bit GUID
binarybyte[]Binary data (reference type)
charcharSingle Unicode character
dateonlyDateOnlyDate without time
timeonlyTimeOnlyTime without date
datetimeoffsetDateTimeOffsetDate/time with UTC offset

3.2 Nullable Types

Append ? to any type to make it nullable:

guild_id : int? // generates: int?
score : float? // generates: float?
name : string // string is already a reference type; no '?' needed for null
avatar : binary? // binary (byte[]) is already a reference type

For value types, ? generates Nullable<T>. For reference types (string, binary), the ? is accepted but does not change the generated CLR type since they are already nullable.

3.3 Array Types

Append [] to any type to generate a C# array:

scores : int[] // generates: int[]
tags : string[] // generates: string[]
points : float[] // generates: float[]

Generated fields are initialized with System.Array.Empty<T>() by default.

3.4 Collection Types — list<T>

Use list<T> for read-only list types:

tags : list<string> // generates: IReadOnlyList<string>
items : list<int> // generates: IReadOnlyList<int>
nested : list<string>? // generates: IReadOnlyList<string> (nullable ref type)

Generated fields are initialized with System.Array.Empty<T>() by default.

3.5 Enum Types

Reference an enum declaration by name as a field type:

enum PlayerStatus { Active = 0, Banned = 1, Inactive = 2 }

table Player {
id : int @id
status : PlayerStatus // generates: PlayerStatus
}

Enum types can be used in table fields, type fields, query parameters, and return types.

3.6 Custom Type References

Fields can reference other type declarations by name:

type Address {
city : string
country : string
}

table Player {
id : int @id
address : Address
}

3.7 Extern Types

Use extern as a type modifier for types defined in external C# code:

// In field declarations:
position : extern Vector3 // generates: Vector3
position : extern Vector3? // generates: Vector3?
view : extern Game.Contracts.PlayerView // generates: Game.Contracts.PlayerView (qualified)
data : extern MyNamespace.MyStruct[] // generates: MyNamespace.MyStruct[]

Extern types pass through to generated code as-is without schema-level validation.

3.8 Extern Type Aliases

Register top-level aliases for frequently used external types:

extern type Vector3 = UnityEngine.Vector3
extern type GeoPoint = Game.Contracts.GeoPoint
extern type GuildView = Game.Contracts.GuildView

Syntax: extern type <Alias> = <Qualified.CLR.Name>

After registration, the alias can be used directly in field types, query parameters, and return types:

table Player {
id : int @id
position : GeoPoint?
}

query GuildViewsClr() -> GuildView[] =
from Guild
| sort title
| select { id -> Id, title -> Title } as extern GuildView

Duplicate alias names produce diagnostic SCH2001.


4 Enum Definitions

4.1 Syntax

enum <Name> [: <BackingType>] {
<Member1> [= <Value>],
<Member2> [= <Value>],

}

4.2 Basic Enums

enum PlayerStatus { Active = 0, Banned = 1, Inactive = 2 }

Generated C#:

namespace Generated;

public enum PlayerStatus
{
Active = 0,
Banned = 1,
Inactive = 2,
}

4.3 Auto-Incrementing Values

Explicit values are optional. Members auto-increment from the previous value (starting at 0):

enum Direction { North, South, East, West }
// equivalent to: North = 0, South = 1, East = 2, West = 3

You can mix explicit and implicit values:

enum Priority { Low = 10, Medium, High }
// Low = 10, Medium = 11, High = 12

4.4 Negative Values

Enum members can have negative values:

enum Temperature { Freezing = -1, Cold = 0, Warm = 1, Hot = 2 }

4.5 Custom Backing Types

Specify a backing integer type after the name with a colon:

enum Rank : byte { Recruit = 0, Veteran = 1, Elite = 2, Officer = 3 }
Backing TypeC# TypeRange
bytebyte0–255
sbytesbyte-128–127
short / int16short-32768–32767
ushort / uint16ushort0–65535
int / int32int(default)
uint / uint32uint0–4294967295
long / int64longSigned 64-bit
ulong / uint64ulongUnsigned 64-bit

Generated C# includes the backing type in the enum declaration:

public enum Rank : byte
{
Recruit = 0,
Veteran = 1,
Elite = 2,
Officer = 3,
}

4.6 Trailing Commas

Trailing commas are allowed and ignored:

enum Flags { A = 1, B = 2, C = 4, }

4.7 Diagnostics

CodeCondition
SCH2001Duplicate enum declaration name
SCH2015Invalid backing type
SCH1008Integer literal out of Int32 range

5 Type Definitions

The type keyword defines plain data structures (DTOs, value objects) without table semantics (no indexes, no primary key, no persistence). They generate C# record or record struct types.

5.1 Syntax

[struct] type <Name> {
<field_name> : <Type>

}

5.2 Reference Types (record)

type PlayerDto {
id : int
name : string
rank : Rank
level : int
score : int
guild_id : int?
}

Generated C#:

namespace Generated;

public record PlayerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public Rank Rank { get; set; }
public int Level { get; set; }
public int Score { get; set; }
public int? GuildId { get; set; }
}

5.3 Value Types (record struct)

struct type Vec2 {
x : float
y : float
}

Generated C#:

namespace Generated;

public record struct Vec2
{
public float X { get; set; }
public float Y { get; set; }
}

5.4 Restrictions

  • Type fields cannot have annotations (@id, @index, @relation, etc.). Attempting to add annotations produces diagnostic SCH1021.
  • Duplicate field names within a type produce diagnostic SCH5005.
  • Field names follow the same snake_casePascalCase conversion as table fields.

6 Table Definitions

6.1 Basic Syntax

table <Name> [(options…)] {
<field_name> : <Type> [annotations…] [= default]

[@@table_annotations…]
}

Every non-struct table must have exactly one @id (primary key) field.

6.2 Table Options

Options are specified in parentheses after the table name, using key: value syntax, comma-separated:

table Inventory(persistence: local, capacity: 1024, schema_version: 2, type_id: 10) {

}
OptionValueDescription
persistencelocal | remote | nonePersistence strategy. Maps to PersistenceType.Local/Remote/None in the generated table descriptor
capacityinteger literalInitial hashtable capacity hint in the generated table descriptor
schema_versioninteger literalSchema version for migration tracking in the generated schema descriptor
type_idinteger literalStable persistence/runtime type discriminator (1-65535) in the generated schema descriptor

All options are optional. Tables with no options omit the parentheses:

table Guild {
id : int @id
name : string
}

Table options use key: value syntax only; = is reserved for field default values.

6.3 Struct Tables

Prefix table with struct to generate a C# record struct (value type):

struct table Position {
id : int @id
x : float
y : float
z : float
}

Generated C#:

[MessagePackObject]
public record struct Position
{
[Key(0)]
public int Id { get; set; }

[Key(1)]
public float X { get; set; }

[Key(2)]
public float Y { get; set; }

[Key(3)]
public float Z { get; set; }
}

6.4 Generated Entity Example

Given:

table Player(persistence: local, capacity: 16384, schema_version: 1, type_id: 1) {
id : int @id
guild_id : int? @relation(references: Guild.id, onDelete: SetNull)
name : string
level : int
score : int
status : PlayerStatus
tags : list<string>
avatar : binary?

@@index(fields: [name], name: "Player_ByName", kind: lookup)
@@index(fields: [level], name: "Player_ByLevel", kind: sorted_set)
}

Generated C#:

using MessagePack;
using ConjureDB;

namespace Generated;

[MessagePackObject]
public record Player
{
[Key(0)]
public int Id { get; set; }

[Key(1)]
public int? GuildId { get; set; }

[Key(2)]
public string Name { get; set; } = string.Empty;

[Key(3)]
public int Level { get; set; }

[Key(4)]
public int Score { get; set; }

[Key(5)]
public PlayerStatus Status { get; set; }

[Key(6)]
public IReadOnlyList<string> Tags { get; set; } = System.Array.Empty<string>();

[Key(7)]
public byte[] Avatar { get; set; } = System.Array.Empty<byte>();
}

6.5 Views And Materialized Views

view and materialized view use the same table-like field body as table, but their source query is always written after the declaration body with = <pipeline>.

view ActiveItemRow {
owner_id : int
item_id : int
name : string
quantity : int
rarity : int
} = from Item i
| filter i.is_deleted == false
| select {
owner_id = i.owner_id,
item_id = i.id,
name = i.name,
quantity = i.quantity,
rarity = i.rarity
}

A view is virtual: it stores no rows, has no indexes, and expands as a named reusable pipeline source.

materialized view PlayerItemRow(
capacity: 50000,
refresh: incremental,
rewrite: explicit
) {
owner_id : int
item_id : int
name : string
quantity : int
rarity : int

@@identity(fields: [item_id])
@@index(fields: [owner_id, rarity desc, name asc],
name: "PlayerItemRow_ByOwnerRarity",
kind: grouped_sorted)
} = from Item i
| filter i.is_deleted == false
| select {
owner_id = i.owner_id,
item_id = i.id,
name = i.name,
quantity = i.quantity,
rarity = i.rarity
}

A materialized view is a derived in-memory relation maintained from authoritative base tables or from another materialized relation through Z-set deltas. It may declare table-level @@identity, @@index, and @@unique. It never declares or emits persistence metadata: persistence, type_id, schema_version, storage, and warm_start are invalid. Field-level annotations and defaults are invalid in both virtual and materialized views.

Options:

OptionValuesv1 execution contract
capacityinteger literalCapacity hint for runtime materialization
refreshincremental | rebuild | manualincremental is executable; rebuild and manual parse into metadata but fail executable binding
rewritenone | explicit | autoexplicit optimizes direct from MaterializedViewName; auto makes the view an optimizer-visible candidate for proven equivalent query prefixes

Queries read both virtual and materialized views as ordinary sources:

query GetPlayerItems(player_id: int, max_count: int) -> PlayerItemRow[] =
from PlayerItemRow
| filter owner_id == @player_id
| sort -rarity, name
| take @max_count

For materialized views, the planner may choose a declared materialized index when the query filter, sort, and limit match the index shape.

Reactive queries over tables or virtual views can create hidden compiler-owned materialized views. Hidden reactive views have origin ReactiveAutoView, internal visibility, no public DSL source name, and no persistence metadata. They are visible to the optimizer, so an ordinary query with the same normalized body can reuse the already maintained hidden relation/index. Explain output prints the hidden origin, visibility, owner reactive query, and selected materialized access.

Materialized view source support is intentionally capability-checked. The executable matrix includes direct base-table pipelines with filter, derive, select, single inner/left/semi/anti equijoins, distinct, union, union all, intersect, intersect all, except, and except all branch materialization, scalar and grouped count/sum/avg/min/max, and bounded sort + take TopK shapes. A materialized view may read a simple virtual view source when that virtual view expands to a direct base-table filter* | select { ... } pipeline. A materialized view may also read another materialized view directly for filter* | select { ... }; the generated context builds source materialized views before dependents and maintains dependents from relation transition deltas. Unsupported composed shapes fail with SCH5027 when they are outside the Z-set materialized maintainer matrix. The SCH5027 message includes a structured reason descriptor with source kind, unsupported operator, dependency path, and supported maintainer family so tooling can report the exact unsupported boundary. View and materialized-view fields are bound in a complete typed relation catalog before projection validation, so references to later declarations are validated the same way as references to earlier declarations.

6.6 Diagnostics

CodeCondition
SCH2001Duplicate table declaration name
SCH2005Table has no primary key field
SCH2006Table has more than one primary key field
SCH2011Duplicate field name after PascalCase normalization
SCH2014Duplicate [Key] ordinal
SCH2020Field count exceeds maximum
SCH5001Duplicate field name in table (pre-bind)
SCH5002@id and @relation on the same field
SCH5003Missing @id on non-struct table (warning)
SCH5007@@index references non-existent field
SCH2034Materialized view refresh mode is parsed but not executable by the in-memory materialized maintainer contract
SCH2038View source projection is missing a declared field
SCH2039View source projection produces an undeclared field
SCH2040View source projection type is not assignable to the declared field
SCH2041View dependency cycle
SCH2042Semi/anti materialized view projection references the right source or is ambiguous
SCH2043Mutation targets a view or materialized view
SCH2044Left-join materialized view projects an unsupported right-source expression
SCH5021Duplicate field name in a view declaration
SCH5022Field-level annotations and defaults are not supported in views
SCH5023Virtual view declares table-level annotations such as @@index or @@identity
SCH5024Materialized view @@index references a missing field
SCH5025Materialized view @@identity references a missing field
SCH5026Materialized view declares an unsupported annotation
SCH5027Materialized view source is outside the supported Z-set materialized maintainer matrix
SCH5028Materialized view @@index uses an unsupported materialized runtime index kind
SCH5029Materialized view index shape is invalid, such as an empty index field list or a grouped_sorted index without both key and sort fields

7 Field Declarations

7.1 Syntax

<field_name> : <Type> [@annotation1] [@annotation2(…)] [= default_value]

7.2 Naming Convention

Field names use snake_case in the schema and are automatically converted to PascalCase in generated C# code:

SchemaGenerated C#
guild_idGuildId
first_nameFirstName
idId

7.3 Default Values

Fields can have default values assigned with = after all annotations:

table Config {
id : int @id
name : string = "default"
count : int = 42
enabled : bool = true
tags : list<string> = []
}

Supported default value forms:

FormExampleDescription
Integer literal= 42Numeric default
String literal= "hello"String default
Identifier= true, = false, = ActiveBoolean or enum member
Empty collection= []Empty array/list default

Automatic defaults — some types receive defaults even without explicit = value:

TypeAuto-default
stringstring.Empty
binarySystem.Array.Empty<byte>()
list<T>System.Array.Empty<T>()
T[] (arrays)System.Array.Empty<T>()

8 Field Annotations

Annotations use the @ prefix and appear after the field type. Multiple annotations can be placed on the same field:

id : int @id
guild_id : int? @relation(references: Guild.id, onDelete: SetNull)
level : int @index(kind: sorted_set, name: "Level_Sorted")
@index(kind: aggregation, name: "Level_Agg", value: level)

8.1 @id — Primary Key

Marks the field as the table's primary key. Every non-struct table must have exactly one @id field.

id : int @id

Constraints:

  • Only one @id per table (diagnostic SCH2006 if multiple).
  • Cannot coexist with @relation on the same field (diagnostic SCH5002).

8.2 @unique — Uniqueness Constraint

Marks the field as having a unique constraint:

email : string @unique

8.3 @default(value) — Default Value (annotation form)

Alternative to the = value suffix syntax for specifying default values:

score : int @default(0)
name : string @default("unknown")
tags : list<string> @default([])

8.4 @index(…) — Index Declaration

Declares an index on the field.

Syntax:

@index(name: "IndexName", kind: <index_type> [, option: value, …])

Minimal form (no parameters, defaults to lookup):

name : string @index

When kind is omitted, the default index type is lookup.

Index types:

KindC# IndexTypeDescription
lookupLookupHash-based O(1) exact-match lookup
sorted_setSortedSetSorted set for range queries and ordered iteration
sorted_listSortedListSorted list index
uniqueUniqueUnique constraint index
aggregationAggregationAggregation index (sum, count, average, etc.)
universal_aggregationUniversalAggregationUniversal aggregation index
range_lookupRangeLookupRange-based lookup
grouped_sortedGroupedSortedGrouped and sorted index
spatial_gridSpatialGridSpatial grid index for coordinate/proximity queries

Index options:

OptionValue TypeApplicable Index TypesDescription
name"string"AllIndex name (used by the generated index structure). Defaults to field PascalCase name
kindidentifierAllIndex type (see table above). Defaults to lookup
valuefield nameaggregation, universal_aggregationValue property for aggregation computation
keys[field1, field2]AllAdditional composite key fields
rangefield namerange_lookupRange property
included[field1, field2]AllIncluded (covering) columns for index-only scans
filter"predicate"AllFilter predicate string for partial/filtered indexes
filter_columns[field1, field2]AllColumns referenced in the filter predicate
encodingidentifierAllKey encoding strategy. Emitted as IndexKeyEncoding.<value>
aggregation_variantall_stats | sum_count | count_only | distinct_countaggregation, universal_aggregationControls which aggregate statistics are maintained
cell_sizenumeric literalspatial_gridGrid cell size for the spatial index
dimensionsinteger literalspatial_gridNumber of spatial dimensions
coordinates[field1, field2]spatial_gridCoordinate fields that form the spatial key
is_pgo_recommendedtrue | falseAllMarks this index as recommended by Profile-Guided Optimization

Examples:

// Simple lookup index
name : string @index(kind: lookup, name: "Name_Lookup")

// Sorted set index
level : int @index(kind: sorted_set, name: "Level_Sorted")

// Aggregation index with value property and variant
level : int @index(kind: aggregation, name: "Level_Agg",
value: level, aggregation_variant: count_only)

// Index with filter predicate
score : int @index(kind: sorted_set, name: "ActiveScore",
filter: "Status == 0", filter_columns: [status])

// Index with included columns for covering queries
name : string @index(kind: lookup, name: "Name_Cover",
included: [level, score])

// PGO-recommended index
score : int @index(kind: sorted_set, name: "Score_Idx",
is_pgo_recommended: true)

// Multiple indexes on the same field
level : int @index(kind: sorted_set, name: "Level_Sorted")
@index(kind: aggregation, name: "Level_Agg", value: level)

Diagnostics:

CodeCondition
SCH2007Unknown index type
SCH2016Invalid aggregation_variant value
SCH2017aggregation_variant used on non-aggregation index
SCH1004Invalid index kind
SCH1006Invalid index option

8.5 @relation(…) — Foreign Key / Relation

Declares a foreign key relationship to another table.

Syntax:

@relation(
references: <Entity>[.<field>],
[name: "RelationName",]
[fields: <field> | [field1, field2],]
[onDelete: <Action>,]
[onUpdate: <Action>]
)

Parameters:

ParameterRequiredDescription
referencesYesTarget entity, optionally with .field (e.g., Guild.id)
nameNoExplicit relation name
fieldsNoSource field(s) for the relation. Single field or bracket list
onDeleteNoReferential action on delete
onUpdateNoReferential action on update

Referential actions:

ActionC# EnumDescription
CascadeReferentialAction.CascadeDelete/update related records
SetNullReferentialAction.SetNullSet foreign key to NULL
RestrictReferentialAction.RestrictPrevent the operation
NoActionReferentialAction.NoActionNo action (database default)
SetDefaultReferentialAction.SetDefaultSet foreign key to default value

Examples:

// Simple FK referencing Guild.id, set null on delete
guild_id : int? @relation(references: Guild.id, onDelete: SetNull)

// Cascading delete
owner_id : int @relation(references: Player.id, onDelete: Cascade)

// With explicit relation name and both actions
player_id : int @relation(
name: "FK_Inventory_Player",
references: Player.id,
onDelete: Cascade,
onUpdate: NoAction
)

// Multi-field relation
owner_id : int @relation(references: Player.id, onDelete: Cascade)

Keep `@relation(...)` declarations single-field. Composite `fields: [...]` lists are not part of the current bound relation contract.

Generated C#:

The @relation annotation records the foreign-key relationship as compiler metadata (used for bounds inference and join planning); it is not emitted as a C# attribute. The generated entity field carries only its MessagePack [Key]:

[Key(1)]
public int? GuildId { get; set; }

Diagnostics:

CodeCondition
SCH2003Foreign key references non-existent table
SCH5002@id and @relation on same field

9 Table Annotations

Table-level annotations use the @@ prefix and appear inside the table body, after all field declarations.

9.1 @@index(…) — Composite Index

Declares a multi-field composite index at the table level.

Syntax:

@@index(
fields: [field1 [asc|desc], field2 [asc|desc], …],
name: "IndexName",
[kind: <index_type>,]
[order: [asc|desc, asc|desc, …],]
[<index_option>: <value>, …]
)

Parameters:

ParameterRequiredDescription
fieldsYesBracket list of field names. Each field may have an inline asc/desc modifier
nameNoIndex name for the generated index property on the …Set class
kindNoIndex type (same values as @index). Defaults to lookup
orderNoSort direction list applied positionally to fields: [asc, desc, asc]

All index options from @index are also valid here (value, keys, range, included, filter, filter_columns, encoding, aggregation_variant, is_pgo_recommended).

Sort directions can be specified in two ways:

  1. Inline with field name: fields: [level desc, score asc]
  2. Separate order list: order: [desc, asc] (applied positionally to fields)

If both are specified, the inline direction takes precedence.

Examples:

table Player {
id : int @id
guild_id : int?
level : int
score : int
name : string

// Simple composite index
@@index(fields: [guild_id, level], name: "Player_ByGuildLevel", kind: sorted_set)

// With sort directions
@@index(fields: [guild_id, score], name: "Player_ByGuildScore",
kind: sorted_set, order: [asc, desc])

// Inline sort directions
@@index(fields: [level desc, score asc], name: "Player_ByLevelScore",
kind: sorted_set)

// Lookup index
@@index(fields: [name], name: "Player_ByName", kind: lookup)

// With PGO flag
@@index(fields: [score], name: "Player_ByScore",
kind: sorted_set, is_pgo_recommended: true)
}

Both @index and @@index emit a strongly-typed index property on the generated …Set class, constructed via a CreateIndex(...) factory call rather than a C# attribute. Composite (multi-field) @@index keys use a value-tuple key selector; single-field indexes use a single-column selector.

Generated C# for composite indexes:

// Multi-field @@index(fields: [guild_id, level], name: "Player_ByGuildLevel", kind: sorted_set):
PlayerByGuildLevelIndex = (SynchronizedIndex<Player>.SortedSetIndex<(int?, int)>)CreateIndex(
global::ConjureDB.IndexType.SortedSet,
static (Player x) => (x.GuildId, x.Level),
null,
null);

9.2 @@unique(…) — Composite Unique Constraint

Shorthand for @@index(…) with forced kind: unique:

@@unique(fields: [email, region])

Equivalent to:

@@index(fields: [email, region], kind: unique)

9.3 @@navigation(…) — Parent-Side Child Collection Navigation

Declares an explicit parent-side child collection navigation for Option 2 schema-first generation. The annotation is metadata only: ConjureDB emits a declarative property on the parent entity, while runtime traversal and mutation stay explicit on generated editors and ChildCollectionHandle APIs. No lazy loading or entity-backed live collection is introduced.

Syntax:

@@navigation(
name: "Orders",
references: Order.customer_id
)

Parameters:

ParameterRequiredDescription
nameYesGenerated parent-side navigation property name
referencesYesChild entity and child FK field in Child.field form

Example:

table Customer {
id: int @id

@@navigation(name: "Orders", references: Order.customer_id)
}

table Order {
id: int @id
customer_id: int @relation(references: Customer.id)
}

Generates a child-collection navigation accessor on the parent entity, reachable through the generated CollectionHandle API and backed by a lookup index on the child's foreign-key field.

Validation rules:

  • name must be a non-empty C#-compatible identifier.
  • references must use explicit Child.field syntax.
  • The referenced child entity must exist.
  • The referenced child FK field must exist on the child entity.
  • A parent table may declare a given child/FK pair only once.

Diagnostics:

CodeCondition
SCH5015Missing or invalid name parameter
SCH5016references is missing or not in Child.field form
SCH5017Navigation name conflicts with another emitted member
SCH5020Duplicate @@navigation for the same child/FK pair
SCH2030Referenced child entity does not exist
SCH2031Referenced child FK field does not exist

10 Query Definitions

10.1 Syntax

query <Name>(<param1>: <type>, …) -> <ReturnType> = <body>

Optional query planning metadata may appear after the return type and before the body:

query <Name>(<params>) -> <ReturnType>
@planning(no_optimize: true, set_op_key_max_values: [9, 4])
= <body>

or with brace body (legacy, still supported):

query <Name>(<param1>: <type>, …) -> <ReturnType> {
<body>
}

10.2 Parameters

Parameters use name: type syntax, comma-separated. Parameters are referenced in the body with @param_name:

query GetTopPlayers(min_level: int, max_count: int) -> Player[] =
from Player
| where level >= @min_level
| sort -score
| take @max_count

Parameter types support the full type system: primitives, enums, extern types, nullable types, arrays, and lists.

10.3 Return Types

Return TypeGenerated C#Description
PlayerPlayerSingle entity
Player[]Player[]Array of entities
string[]string[]Array of scalars
intintSingle scalar
list<string>[]IReadOnlyList<string>[]Array of lists
PlayerDto[]PlayerDto[]Array of custom types
extern Game.Views.PV[]Game.Views.PV[]Array of extern types

10.4 Planning Hints

@planning(...) is a typed schema-owned carrier for bounded workload facts that must reach the compiler planning profile. Use it for benchmark/proof schemas where the query source already knows closed-world domain limits. Do not encode planner flags in method names.

Supported options:

OptionTypeDescription
no_optimizeboolDisable optimizer/PGO strategy selection for explicit A/B comparison queries
max_key_value / min_key_valueintDense key bounds for scalar-key planning
max_group_key_valueintDense group-key bound
expected_result_countintTrusted positive expected output row count for bounded planning and preallocation
max_result_countintTrusted positive maximum output row count for bounded TopK/result-capacity planning
set_op_key_max_values / set_op_key_min_valuesint[]Per-component set-op comparison-key bounds
set_op_right_distinct_countintRight-side distinct key count for set-op early-exit tuning
aggregate_group_key_max_values / aggregate_group_key_min_valuesint[]Per-component grouped-aggregate key bounds
aggregate_group_key_string_value_setsstring[]Per-component grouped-aggregate string domains, encoded as pipe-separated values
aggregate_distinct_value_max_values / aggregate_distinct_value_min_valuesint[]Per-aggregate count(distinct ...) value bounds
aggregate_distinct_value_string_value_setsstring[]Per-aggregate distinct string domains, encoded as pipe-separated values

Example:

query GetTopStatusBuckets(limit: int) -> StatusBucketView[]
@planning(
aggregate_group_key_string_value_sets: ["Pending|Completed|Cancelled"],
aggregate_distinct_value_max_values: [99]
) {
from Order o
| group o.Status (aggregate { DistinctPlayers = count(distinct o.PlayerId) })
| sort -DistinctPlayers
| take @limit
}

10.5 Query Body — Pipeline Operators

The query body uses the ConjureDB DSL pipeline syntax. It starts with a source clause followed by zero or more pipeline steps separated by |:

from <Entity> [<alias>]
| <step1>
| <step2>
| …

Source clauses:

SourceSyntaxDescription
Table scanfrom <Entity>Read all rows from the entity
Table scan with aliasfrom <Entity> <alias>Aliased table scan
Virtual viewfrom <ViewName>Expand a reusable logical view source
Materialized viewfrom <MaterializedViewName>Read maintained in-memory relation/index state
Fragment invocation@fragment_name(args)Expands the fragment body

Pipeline steps:

StepSyntaxDescription
where / filter| where <predicate>Filter rows by predicate. where is auto-substituted to filter for compiler compatibility
sort| sort [-]field1 [, [-]field2]Sort results. Prefix - for descending
take / limit| take <n>Limit result count
skip| skip <n>Skip first N results
select| select field1, field2Project specific fields
select … as| select { f1, f2 } as TypeNameProject into a named DTO
select … as extern| select { … } as extern ExternalTypeProject into an extern CLR type
join| join <Entity> [<alias>] (<predicate>)Inner join
left join| left join <Entity> [<alias>] (<predicate>)Left outer join
right join| right join <Entity> [<alias>] (<predicate>)Right outer join
full join| full join <Entity> [<alias>] (<predicate>)Full outer join
semi join| semi join <Entity> [<alias>] (<predicate>)Semi join
anti join| anti join <Entity> [<alias>] (<predicate>)Anti join
distinct| distinctRemove duplicate rows
group| group <expr>Group by expression
require found| require foundAssert at least one result
require confirm_full_table| require confirm_full_tableConfirm full table scan intent
union| union <expr>Set union
intersect| intersect <expr>Set intersection
except| except <expr>Set difference

10.6 Reactive Queries

Prefix query with reactive to generate a ReactiveQuery<T> return type instead of a static array:

reactive query WatchTopPlayers(min_level: int) -> Player[] =
from Player
| where level >= @min_level
| sort -score
| take 10

Generated C#:

ReactiveQuery<Player> WatchTopPlayers(int minLevel);

10.7 Generated Query Interface

Queries are grouped by their source entity (from <Table>) into repository interfaces:

using ConjureDB;

namespace Generated;

public interface IPlayerQueries : IRepository<Player>
{
Player[] GetTopPlayers(int minLevel);

Player FindPlayer(string name);
}

10.7 Diagnostics

CodeCondition
SCH2018Query body must start with from <TableName> after fragment expansion
SCH2018Query source table not declared
SCH1007Empty query body

11 Mutation Definitions

11.1 Syntax

mutation <Name>(<params…>) [-> <ReturnType>] = <body>

or with brace body:

mutation <Name>(<params…>) [-> <ReturnType>] {
<body>
}

11.2 Mutation Verbs

The mutation body starts with a verb instead of from:

VerbSyntaxDescription
updateupdate <Entity>Update existing records
deletedelete <Entity>Delete records
insertinsert <Entity>Insert new records
upsertupsert <Entity>Insert or update records

11.3 Mutation-Specific Pipeline Steps

StepSyntaxApplicable VerbsDescription
set| set field = valueupdateSet field values
values| values { field1 = value, … }insert, upsertSpecify field values for insertion
assert| assert <expr>AllAssert a condition
returning| returning <expr>AllReturn specific fields
require confirm_full_table| require confirm_full_tableupdate, deleteConfirm full-table operation intent

11.4 Return Types

DeclarationReturn TypeDescription
mutation Ban(…)voidNo return value
mutation Ban(…) -> intintAffected row count
mutation Create(…) -> PlayerPlayerSingle entity
mutation Batch(…) -> Player[]Player[]Array of entities
command Buy(…) -> ResultBuyOutcome record struct (IsSuccess, Result, ErrorCode, ErrorPayload) plus a BuyErrorCode enum, a BuyErrorPayload struct, and command handler metadataDeterministic command contract

11.5 Examples

// Void mutation (no return type)
mutation BanPlayer(player_id: int) =
update Player
| where id == @player_id
| set status = 1

// Returns affected row count
mutation DeleteInactivePlayers() -> int =
delete Player
| where status == 2

// Returns created entity
mutation CreatePlayer(name: string, level: int) -> Player =
insert Player
| values { name = @name, level = @level, score = 0, status = 0 }

// Full-table update with safety guard
mutation ResetGuildTitles() -> int =
update Guild
| set title = "reset"
| require confirm_full_table

// Upsert
mutation UpsertPlayer(name: string) -> Player =
upsert Player
| values { name = @name }

11.6 Generated Mutation Interface

Mutations are grouped by their target entity into mutation interfaces:

using ConjureDB;

namespace Generated;

public interface IPlayerMutations : IRepository<Player>
{
void BanPlayer(int playerId);

int DeleteInactivePlayers();

Player CreatePlayer(string name, int level);
}

12 Command Definitions

Commands describe validated game/backend operations at the schema layer. They are emitted as typed command request/result models, deterministic handlers, error-code manifests, operation metadata, and context registration code. Commands are for operation contracts; table queries and simple CRUD-style mutations should remain query/mutation declarations.

12.1 Syntax

command <Name>(<params…>) [-> <ResultType>]
kind <local | local_or_online | online_player | admin | server_job | internal>
[auth <policy-or-none>]
[idempotent by <param> [optional]]
[scope <scope-name>(<param>, …) [optional] | none]
{
<statement>*
}

kind defaults to local when omitted by the emitted metadata path. auth, idempotent, and scope are metadata clauses; parameter references inside them must name command parameters exactly.

Commands must be declared inside module blocks. A module may import command namespaces for short-name resolution:

module Shop {
using Economy
using Game.Inventory as Inv
}

12.2 Body Statements

StatementSyntaxDescription
Variablevar name = <expression>Binds a pure value, command invocation, or query pipeline. Names share scope with parameters and prior variables.
Requirerequire <condition> else ErrorName [{ field: expression, … }]Fails the command with a typed error code and optional payload when the condition is false.
Writeupdate / upsert / insert / delete pipeline statementPerforms one explicit single-row state transition. Writes are not valid behind var.
Eventevent EventName { field: expression, … }Adds an emitted event effect and validates payload expressions.
Returnreturn { field: expression, … }Returns an object payload matching the declared result type.

Command expressions may reference parameters, prior variables, object members, and deterministic context paths. Supported context paths are ctx.now, ctx.tick, ctx.contentVersion, ctx.seasonId, ctx.random.next_int, and ctx.random.next_uint64.

Object-like command payloads use field: expression syntax. set remains assignment-oriented and supports compound assignments such as Amount += quantity.

Writes are command statements, not var pipelines. Upsert binds the inserted-or-updated row through returning ... into <name>:

upsert InventorySlot
| key { PlayerId: playerId, ItemId: itemId }
| set Amount += quantity
| returning { amountAfter: Amount } into inventoryAfter

12.3 Command Pipelines

A command variable can bind a constrained read pipeline:

var wallet =
from Wallet
| filter PlayerId == playerId
| single else WalletMissing { playerId: playerId }

Single-row write statements use explicit keys or values instead of filters:

update Wallet
| key { Id: wallet.Id } else WalletMissing { playerId: playerId }
| require Balance >= cost else InsufficientFunds { balance: Balance, cost: cost }
| set Balance -= cost, Version += 1
| returning { balanceAfter: Balance, version: Version } into walletAfter

upsert InventorySlot
| key { PlayerId: playerId, ItemId: itemId }
| set Amount += quantity
| returning { amountAfter: Amount } into inventoryAfter

insert Receipt
| values { Id: receiptId, PlayerId: playerId, ReceiptKey: receiptKey, Amount: amount }
| conflict else DuplicateReceipt { receiptKey: receiptKey }
| returning { receiptId: Id, amount: Amount } into receiptAfter

delete Receipt
| key { Id: receiptId } else ReceiptMissing { receiptId: receiptId }
| returning { receiptKey: ReceiptKey } into deletedReceipt

Supported query pipeline operators are filter, select, and the terminal operators single, single_or_default, first, first_or_default, any, count, and to_list. Terminals must use lower-case PRQL-style spelling and every command read pipeline must end with one explicit terminal.

Supported write statement forms are:

OperationRequired clausesOptional clausesNotes
updatekey ... else, setrequire ... else, returning ... intoMissing row is a typed error from the key clause.
upsertkey, setreturning ... intoInsert-or-update by primary key or declared unique index.
insertvaluesconflict else, returning ... intoRequired fields must be supplied unless schema proves a generated/default value.
deletekey ... elserequire ... else, returning ... intoreturning projects the pre-delete row.

Command write statements reject filter, reject terminals such as single, reject returning without into, and never generate a full-scan fallback. key must resolve to the table primary key or a named unique index.

12.4 Subcommands

Commands can call other schema commands directly:

var debit = SpendGold(playerId: playerId, amount: cost)

Calls resolve by short name in the current module, then imported using modules, then by fully-qualified canonical identity. Alias-qualified calls such as Inv.GrantItem(...) resolve through using Game.Inventory as Inv. Ambiguous imported short names are hard binding errors. Arguments may be positional or named, but a single call cannot mix both forms. Command call graphs must be acyclic.

12.5 Generated Command Artifacts

For each command, schema emission produces deterministic C# artifacts in generated files:

ArtifactPurpose
Request recordTyped command input with PascalCase properties for parameters.
Outcome record struct ({CommandName}Outcome)Typed carrier with IsSuccess, Result, ErrorCode, and ErrorPayload.
Error enum ({CommandName}ErrorCode)None plus errors declared by require, pipeline else, and invoked subcommands.
Handler classDeterministic command execution over the generated context/editor APIs; internal commands emit executors and metadata but are not externally registered as handlers.
Metadata classCanonical identity, CommandKind, authorization, idempotency, scope descriptors, and effect manifest.
Context registration partialRegisters external handlers and all command metadata in deterministic canonical-identity order.

Generated command code is output of the schema compiler. Do not edit generated command files manually; change the .conjure command declaration or the schema emitter, then regenerate.

12.6 Example

module Shop {
table Wallet {
id: int @id
playerId: int
balance: long
version: int

@@unique(fields: [playerId], name: "Wallet_ByPlayer")
}

table InventorySlot {
id: int @id
playerId: int
itemId: int
amount: int

@@unique(fields: [playerId, itemId], name: "InventorySlot_ByPlayerItem")
}

type PurchaseResult {
balanceAfter: long
version: int
inventoryAmount: int
}

command BuyItem(playerId: int, itemId: int, quantity: int, requestId: guid, cost: long) -> PurchaseResult
kind local_or_online
idempotent by requestId optional
scope player(playerId) optional
{
update Wallet
| key { PlayerId: playerId } else WalletMissing { playerId: playerId }
| require Balance >= cost else InsufficientFunds {
balance: Balance,
cost: cost
}
| set Balance -= cost,
Version += 1
| returning { balanceAfter: Balance, version: Version } into walletAfter

upsert InventorySlot
| key { PlayerId: playerId, ItemId: itemId }
| set Amount += quantity
| returning { amountAfter: Amount } into inventoryAfter

event ItemPurchased {
playerId: playerId,
cost: cost,
itemId: itemId,
quantity: quantity,
balanceAfter: walletAfter.BalanceAfter
}

return {
balanceAfter: walletAfter.BalanceAfter,
version: walletAfter.Version,
inventoryAmount: inventoryAfter.AmountAfter
}
}
}

12.7 Diagnostics

Command binding diagnostics are documented in Error Codes, including SCH2110 through SCH2149.


13 Fragment Definitions

13.1 Syntax

fragment <name>(<params…>) = <body>

or with brace body:

fragment <name>(<params…>) {
<body>
}

13.2 Defining Fragments

Fragments define reusable pipeline snippets. They must start with a source clause (from <Entity>):

fragment high_level_players(min_level: int) =
from Player
| where level >= @min_level

fragment players_in_guild(gid: int) =
from Player
| where guild_id == @gid

fragment all_players() =
from Player

13.3 Invoking Fragments

Invoke fragments with @fragment_name(args) at the beginning of a query or mutation body:

query ListHighLevelPlayers(min_level: int) -> Player[] =
@high_level_players(@min_level)
| sort -score
| take 100

query ListGuildPlayers(gid: int) -> Player[] =
@players_in_guild(@gid)
| sort -level
| take 50

Fragment parameters are substituted textually — @min_level in the fragment body is replaced with the argument from the call site.

13.4 Zero-Parameter Fragments

Fragments with no parameters use empty parentheses:

fragment all_guilds() =
from Guild

query ListGuilds() -> Guild[] =
@all_guilds()
| sort title

13.5 Fragment Cycle Detection

Fragment references must be acyclic. If fragment A references fragment B and fragment B references fragment A, the validator reports an error. Detection happens during the pre-bind semantic validation phase, using graph traversal over @Name( patterns in fragment bodies (skipping string literals and comments).

Diagnostics:

CodeCondition
SCH2001Duplicate fragment declaration name

14 Import System

14.1 File Imports

import "path/to/file.conjure"

Import paths are always relative to the directory of the file containing the import statement. Absolute paths are not supported.

14.2 Module Resolution

When the schema CLI scans a directory, it discovers all *.conjure files recursively. The import graph determines compilation order:

  1. Files with no imports are compiled first.
  2. Files that import other files are compiled after their dependencies.
  3. All declarations from all files are merged into a single schema.
  4. Duplicate declaration names across files produce errors.

14.3 Circular Import Handling

Circular imports are detected and rejected:

// a.conjure
import "b.conjure"

// b.conjure
import "a.conjure" // ← SCH3003: Import cycle detected

14.4 Import Diagnostics

CodeCondition
SCH3002Import target file not found in the schema file set
SCH3003Import cycle detected
SCH3004Duplicate canonical file identity (two files normalize to the same path)

For CLI, LSP, migration tools, diagnostics, examples, and best practices, see Schema Tooling.

For additional documentation see: