Skip to main content

ConjureDB Schema Tooling Reference

CLI commands, LSP support, migration integration, diagnostics, examples, and best practices for the ConjureDB Schema DSL. For schema syntax reference, see Schema Language.


1 CLI Reference

1.1 ConjureDB.Schema.Cli — Validation + Metadata Generation

Supported path after wave one: ConjureDB.Schema.Cli validates .conjure files and emits metadata C#. To get an executable DbContext and runnable queries, include the emitted C# in a project that also runs ConjureDB.CodeGen (or ConjureDB.CodeGen.Manual).

Usage:

dotnet run --project ConjureDB.Schema.Cli -- <schema-dir> <output-dir> [options]

Positional arguments:

ArgumentDescription
<schema-dir>Directory containing .conjure files (searched recursively)
<output-dir>Directory where generated .g.cs files are written (created if not exists)

Options:

OptionDefaultDescription
--context=<Name>AppDbContextName of the generated DbContext subclass
--namespace=<Name>GeneratedC# namespace for all generated code
--validateoffRun parse/import/merge/bind validation without writing .g.cs output

Example:

dotnet run --project ConjureDB.Schema.Cli -- \
./schemas ./Generated \
--context=GameDbContext \
--namespace=Game.Data

1.2 Pipeline Stages and Output

The CLI is fail-fast. It processes files in stages, prints progress to stdout and diagnostics to stderr, and stops before emission if any stage reports errors:

Found 3 .conjure file(s)
Emitting to ./Generated...
→ PlayerStatus.g.cs
→ Player.g.cs
→ Guild.g.cs
→ IPlayerQueries.cs
→ IPlayerMutations.cs
→ GameDbContext.g.cs
Done. 6 file(s) generated.

1.3 Exit Codes

CodeMeaning
0Success — all files generated
1Error — parse, import, merge, bind, or I/O errors
2Unexpected internal error (unhandled exception)

1.4 Generated File Types

File PatternSource DeclarationContent
<EnumName>.g.csenumC# enum type
<TableName>.g.cstable / struct tableEntity record / record struct with attributes
<TypeName>.g.cstype / struct typePlain record / record struct
I<Entity>Queries.csquery / reactive queryRepository interface with query methods
I<Entity>Mutations.csmutationRepository interface with mutation methods
<ModuleInterfaceName>.g.csmoduleModule interface containing query/mutation members
<ContextName>.g.cs(all tables)executable partial class <ContextName> : DbContext with schema descriptor registration

1.5 Generated DbContext

The generated DbContext exposes a typed set for each table — a generated concrete <Plural>Set (a DbSet<T> subclass) — and registers it via AddSet inside Build():

using System;
using System.Collections.Generic;
using ConjureDB;

namespace Game.Data;

public partial class GameDbContext : DbContext
{
public PlayersSet Players { get; private set; } = null!;
public GuildsSet Guilds { get; private set; } = null!;
public InventoriesSet Inventories { get; private set; } = null!;

public GameDbContext(DbConfiguration config) : base(config)
{
}

protected override void Build()
{
var relResolversPlayer = new List<IRelationResolver<Player>>(0);
Players = new PlayersSet(this, static (Player x) => x.Id,
static (Player x, int id) => x.Id = id, relResolversPlayer, 16384);
AddSet(Players);

var relResolversGuild = new List<IRelationResolver<Guild>>(0);
Guilds = new GuildsSet(this, static (Guild x) => x.Id,
static (Guild x, int id) => x.Id = id, relResolversGuild, 256);
AddSet(Guilds);

var relResolversInventory = new List<IRelationResolver<Inventory>>(0);
Inventories = new InventoriesSet(this, static (Inventory x) => x.Id,
static (Inventory x, int id) => x.Id = id, relResolversInventory, 16);
AddSet(Inventories);
}
}

The supported full pipeline is:

  1. Add .conjure files as schema AdditionalFiles.
  2. Let the source generator run the shared SchemaPipelineRunner path.
  3. Use ConjureDB.CodeGen.Manual only when intentionally regenerating checked-in generated packs.

Pluralization rules:

  • Names ending in s, x, z, ch, sh → append es (e.g., MatchMatches)
  • Names ending in consonant + y → replace y with ies (e.g., InventoryInventories)
  • Otherwise → append s (e.g., PlayerPlayers)

2 LSP Support

The ConjureDB.Schema.Lsp project provides a Language Server Protocol server for .conjure files.

2.1 Editor Integration

The LSP server is launched by the vscode-conjure VSCode extension. It communicates via stdin/stdout using the standard LSP protocol.

To install:

cd vscode-conjure
npm ci && npm run compile && npm run package:vsix && npm run install:vsix

2.2 Supported LSP Features

FeatureHandlerDescription
DiagnosticsTextDocumentSyncHandlerReal-time parse and binding errors as you type
Go-to-definitionDefinitionHandlerNavigate to declaration of tables, enums, types, fragments
Find referencesReferencesHandlerFind all usages of a symbol across the import closure
CompletionCompletionHandlerContext-aware suggestions for types, tables, enums, fields, annotations
HoverHoverHandlerType information and declaration details on hover
Document symbolsDocumentSymbolHandlerOutline view of tables, enums, queries, mutations, fragments

2.3 Import Closure Scope

For any active document, the LSP builds a transitive import closure (the active file plus all recursively imported files) and runs all language features on that closure:

  • Go-to-definition resolves symbols from imported files.
  • Find references collects references across the closure.
  • Completion includes tables/enums/queries/mutations from imported files.
  • Hover describes symbols declared in imported files.

Imported files are loaded from disk on demand, even if not currently open in the editor.

Current limitations:

  • The LSP does not build a full project-wide semantic index.
  • IntelliSense scope is limited to the active file's transitive import closure.
  • Symbols in unrelated workspace files are excluded until reachable through imports.

2.4 VSCode Extension Features

  • Syntax highlighting — keywords, types, annotations, operators, strings, comments
  • Bracket matching and auto-closing{}, [], (), ""
  • Code folding — fold table, query, mutation, fragment blocks
  • Full LSP integration — all features listed above

2.5 Module Support

  • Syntax highlighting — the module keyword is highlighted as a declaration keyword.
  • Completions — the LSP provides context-aware completions inside module bodies (tables, queries, mutations, fragments, etc.).
  • Document outline — modules appear as top-level symbols with nested tables, queries, and mutations in the outline view.
  • Snippets — type module or ummod to expand the module declaration snippet.

3 Migration Integration

3.1 Schema Versioning

Use schema_version and type_id table options to enable migration tracking:

table Player(persistence: local, type_id: 1, schema_version: 3) {
id : int @id
name : string
score : long // was: int in version 2
}

Generated descriptor metadata:

new EntitySchemaDescriptor(
typeId: 1,
schemaVersion: 3,
entityName: "Player",
fields: PlayerFields)

3.2 Compatibility, Auto-Handled Changes, and Manual Migration

When loading persisted snapshots, ConjureDB compares the stored schema fingerprint against the current definition and classifies the change as identical, backward-compatible, requiring migration, or incompatible. Adding/removing fields and widening nullability are auto-handled; breaking changes (type change, narrowed nullability) require an EntityMigrationBuilder registered in DbContext.OnBeforeBuild(). Single-step migrations (v1→v2, v2→v3) are resolved into contiguous, forward-only chains automatically. See Schema Migration for the compatibility verdict table (Overview), auto-handled changes, the builder's KeepField/DropField/AddField/TransformField operations (Field Operations), and migration chains.


4 Diagnostic Reference

4.1 Code Ranges

Code RangePhaseDescription
SCH0xxxLexerUnterminated strings, unexpected characters
SCH1xxxParserUnexpected tokens, invalid options, empty bodies
SCH2xxxBinderDuplicate names, unknown types, FK validation, PK validation
SCH3xxxImport graphMissing imports, cycles, duplicate file identities
SCH4xxxSingle-file import resolutionCircular imports, missing/unreadable imported files, invalid import paths
SCH5xxxSemantic validatorStructural invariants (pre-bind)

4.2 Common Diagnostics

This lists the most frequently encountered codes; it is not exhaustive (each phase defines additional codes — see the ranges above).

CodePhaseSeverityDescription
SCH0001LexerErrorUnterminated string literal
SCH0002LexerWarningUnknown escape sequence in a string literal
SCH0003LexerErrorUnexpected character that cannot start any valid token
SCH1001ParserErrorExpected token not found (type name, default value, body delimiter)
SCH1002ParserErrorExpected a specific keyword after another (e.g. type after extern, query after reactive)
SCH1003ParserErrorInvalid table option (unknown key or invalid persistence value)
SCH1004ParserErrorInvalid index kind
SCH1005ParserErrorInvalid annotation (@ or @@ with unknown keyword)
SCH1006ParserErrorInvalid index option key or value
SCH1007ParserErrorEmpty body (query, mutation, or fragment)
SCH1008ParserErrorInteger literal out of Int32 range
SCH1021ParserErrorType field does not support annotations
SCH2001BinderErrorDuplicate declaration name (enum, table, type, query, mutation, fragment, extern alias)
SCH2003BinderErrorForeign key references non-existent table
SCH2004BinderErrorCritical reference error (abort emission)
SCH2005BinderErrorTable has no primary key field
SCH2006BinderErrorTable has multiple primary key fields
SCH2007BinderErrorUnknown index type
SCH2011BinderErrorDuplicate field name after PascalCase normalization
SCH2014BinderErrorDuplicate [Key] ordinal in table
SCH2015BinderErrorInvalid enum backing type
SCH2016BinderErrorInvalid aggregation_variant value
SCH2017BinderErroraggregation_variant on non-aggregation index
SCH2018BinderErrorQuery body must start with from <Table> / source table not declared
SCH2020BinderErrorField count exceeds maximum
SCH3002ImportErrorMissing import target
SCH3003ImportErrorImport cycle detected
SCH3004ImportErrorDuplicate canonical file identity
SCH5001ValidatorErrorDuplicate field name in table (pre-bind)
SCH5002ValidatorError@id and @relation on same field
SCH5003ValidatorWarningMissing @id on non-struct table
SCH5005ValidatorErrorDuplicate field name in type
SCH5006ValidatorErrorType field has table annotations
SCH5007ValidatorError@@index field does not exist in table

5 Examples

5.1 Complete Game Schema

// game.conjure — Full game schema example

// ═══════════════════════════════════
// Enums
// ═══════════════════════════════════

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

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

// ═══════════════════════════════════
// Custom Types (DTOs)
// ═══════════════════════════════════

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

type GuildDto {
id : int
title : string
rank : Rank
}

// ═══════════════════════════════════
// Extern Type Aliases
// ═══════════════════════════════════

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

// ═══════════════════════════════════
// Tables
// ═══════════════════════════════════

table Guild(persistence: local, capacity: 256) {
id : int @id
title : string @unique
rank : Rank
leader_id : int?

@@index(fields: [title], name: "Guild_ByTitle", kind: lookup)
@@index(fields: [rank], name: "Guild_ByRank", kind: lookup)
}

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

@@index(fields: [guild_id], name: "Player_ByGuild", kind: lookup)
@@index(fields: [name], name: "Player_ByName", kind: lookup)
@@index(fields: [level], name: "Player_ByLevel", kind: sorted_set)
@@index(fields: [score], name: "Player_ByScore", kind: sorted_set,
is_pgo_recommended: true)
@@index(fields: [guild_id, level], name: "Player_ByGuildLevel",
kind: sorted_set, order: [asc, desc])
@@index(fields: [guild_id, score], name: "Player_ByGuildScore",
kind: sorted_set, order: [asc, desc])
}

table Item {
id : int @id
owner_id : int @relation(references: Player.id, onDelete: Cascade)
name : string
rarity : int

@@index(fields: [owner_id], name: "Item_ByOwner", kind: lookup)
}

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

table Inventory(type_id: 10, schema_version: 2) {
id : int @id
player_id : int @relation(references: Player.id, onDelete: Cascade)
data : binary
tags : list<string>
position : Vector3
}

// ═══════════════════════════════════
// Fragments
// ═══════════════════════════════════

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

// ═══════════════════════════════════
// Queries
// ═══════════════════════════════════

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

query GetGuildById(id: int) -> Guild =
from Guild
| where id == @id
| sort id
| take 1
| require found

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

query ListPlayerNames(min_level: int) -> string[] =
from Player
| where level >= @min_level
| sort name
| select name

query PlayerTagBuckets() -> list<string>[] =
from Player
| select tags

query TopPlayersDto(min_level: int) -> PlayerDto[] =
from Player
| where level >= @min_level
| sort -score
| take 100
| select { id, name, rank, level, score, guild_id } as PlayerDto

query GuildDtos() -> GuildDto[] =
from Guild
| sort title
| select { id, title, rank } as GuildDto

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

// ═══════════════════════════════════
// Reactive Queries
// ═══════════════════════════════════

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

// ═══════════════════════════════════
// Mutations
// ═══════════════════════════════════

mutation ResetGuildTitles() -> int =
update Guild
| set title = "reset"
| require confirm_full_table

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

mutation DeleteInactivePlayers() -> int =
delete Player
| where status == 2

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

mutation PromotePlayers(min_level: int) -> int =
update Player
| where level >= @min_level
| set rank = Rank.Elite

5.2 Multi-File Project

schema/core/types.conjure:

enum Rank : byte { Bronze = 0, Silver = 1, Gold = 2 }

type Currency {
code : string
amount : decimal
}

extern type Vector3 = UnityEngine.Vector3

schema/entities/guild.conjure:

import "../core/types.conjure"

table Guild(persistence: local, capacity: 256) {
id : int @id
name : string @unique
rank : Rank

@@index(fields: [name], name: "Guild_ByName", kind: lookup)
}

schema/entities/player.conjure:

import "../core/types.conjure"
import "guild.conjure"

table Player(persistence: local, capacity: 16384) {
id : int @id
guild_id : int? @relation(references: Guild.id, onDelete: SetNull)
name : string
rank : Rank
balance : Currency

@@index(fields: [name], name: "Player_ByName", kind: lookup)
@@index(fields: [guild_id], name: "Player_ByGuild", kind: lookup)
}

fragment active_players() =
from Player
| where rank != Rank.Bronze

query TopPlayers() -> Player[] =
@active_players()
| sort -rank
| take 100

Generate:

dotnet run --project ConjureDB.Schema.Cli -- \
./schema ./Generated \
--context=GameDbContext \
--namespace=Game.Data

5.3 Complex Index Configurations

table Analytics(persistence: local, capacity: 65536) {
id : int @id
user_id : int
event_type : int
timestamp : datetime
value : float
region : string

// Hash lookup for user queries
@@index(fields: [user_id], name: "Analytics_ByUser", kind: lookup)

// Sorted index for time-range queries
@@index(fields: [timestamp], name: "Analytics_ByTime", kind: sorted_set)

// Composite sorted index with direction control
@@index(fields: [event_type, timestamp],
name: "Analytics_ByEventTime",
kind: sorted_set,
order: [asc, desc])

// Aggregation index for sum/count queries
@@index(fields: [event_type],
name: "Analytics_EventAgg",
kind: aggregation,
value: value,
aggregation_variant: sum_count)

// Filtered index for active regions only
@@index(fields: [region],
name: "Analytics_ActiveRegion",
kind: lookup,
filter: "EventType > 0",
filter_columns: [event_type])

// Index with covering columns
@@index(fields: [user_id],
name: "Analytics_UserCover",
kind: sorted_set,
included: [event_type, value])

// PGO-recommended index
@@index(fields: [user_id, event_type],
name: "Analytics_UserEvent",
kind: sorted_set,
is_pgo_recommended: true)
}

5.4 Schema with All Type Variants

extern type CustomData = MyApp.Types.CustomData

enum Priority { Low = 0, Medium = 1, High = 2, Critical = 3 }

type Metadata {
created_by : string
created_at : datetime
version : int
}

struct type Coordinate {
lat : double
lng : double
}

table Task(persistence: local, capacity: 4096) {
id : int @id
title : string
priority : Priority
metadata : Metadata
location : Coordinate?
tags : list<string>
scores : int[]
binary_data : binary
custom : extern CustomData?
deadline : datetime?
duration : timespan
ref_id : guid
is_active : bool = true
weight : float = 1.0
counter : long = 0

@@index(fields: [priority], name: "Task_ByPriority", kind: sorted_set)
@@index(fields: [is_active, priority], name: "Task_ActivePriority",
kind: sorted_set, order: [asc, desc])
}

6 Best Practices

6.1 Naming Conventions

  • Files: use snake_case.conjure or descriptive names (game.conjure, analytics.conjure).
  • Tables: use PascalCase singular nouns (Player, Guild, InventoryItem).
  • Fields: use snake_case in schema (guild_id, created_at); they auto-convert to PascalCase in C#.
  • Enums: use PascalCase for both type and members (PlayerStatus, Active).
  • Indexes: use descriptive names with entity prefix (Player_ByGuild, Analytics_ByEventTime).
  • Queries/Mutations: use PascalCase verb-noun (GetTopPlayers, BanPlayer, DeleteInactive).
  • Fragments: use snake_case descriptive names (active_players, high_level_players).

6.2 Index Strategy

  • Start with lookup indexes on fields used in where equality filters.
  • Use sorted_set indexes on fields used in range predicates or sort clauses.
  • Add composite indexes when queries frequently filter/sort on field combinations.
  • Use included columns to create covering indexes and avoid table lookups.
  • Apply aggregation_variant: count_only when only counts are needed (reduces memory).
  • Use is_pgo_recommended: true to mark PGO-suggested indexes for documentation.

6.3 Schema Organization

  • One file per domain area — keep related tables, queries, and mutations together.
  • Extract shared types into a core/ or shared/ directory and use imports.
  • Use extern type aliases for frequently referenced CLR types.
  • Use fragments to avoid duplicating common filter/sort pipelines.
  • Keep query bodies concise — use = expression bodies for single-pipeline queries.

6.4 Version Management

  • Always assign type_id to persisted tables — auto-generated IDs can shift.
  • Increment schema_version on every breaking change.
  • Never reuse [Key] ordinals for fields with different types.
  • Prefer backward-compatible changes — add nullable fields instead of changing types.
  • Register migration chains contiguously (v1→v2, v2→v3 — no gaps).

7 Appendix: Grammar Summary

file = { import | extern_type | enum | type | table | query
| reactive_decl | mutation | fragment } ;

import = "import" STRING ;
extern_type = "extern" "type" IDENT "=" qualified_name ;

enum = "enum" IDENT [ ":" backing_type ] "{" enum_members "}" ;
enum_members = enum_member { "," enum_member } [ "," ] ;
enum_member = IDENT [ "=" [ "-" ] INT ] ;

type = [ "struct" ] "type" IDENT "{" { type_field } "}" ;
type_field = IDENT ":" type_ref ;

table = [ "struct" ] "table" IDENT [ table_options ] "{" { field | table_ann } "}" ;
table_options = "(" option { "," option } ")" ;
option = ( "persistence" | "capacity" | "schema_version" | "type_id" | "plural" )
( ":" | "=" ) value ;

field = IDENT ":" type_ref { field_ann } [ "=" default_value ] ;
field_ann = "@" ( "id" | "unique" | "default" "(" value ")"
| "index" [ "(" index_params ")" ]
| "relation" [ "(" relation_params ")" ] ) ;

table_ann = "@@" ( "index" | "unique" ) "(" composite_params ")" ;

type_ref = [ "extern" ] ( scalar_type | IDENT | "list" "<" type_ref ">" )
[ "?" ] [ "[]" ] ;

query = "query" IDENT "(" params ")" "->" type_ref ( "=" body | "{" body "}" ) ;
reactive_decl = "reactive" query ;
mutation = "mutation" IDENT "(" params ")" [ "->" type_ref ]
( "=" body | "{" body "}" ) ;
fragment = "fragment" IDENT "(" params ")" ( "=" body | "{" body "}" ) ;

body = source_clause { "|" pipeline_step } ;
source_clause = "from" IDENT [ IDENT ]
| ( "update" | "delete" | "insert" | "upsert" ) IDENT
| "@" IDENT "(" args ")" ;

pipeline_step = "where" expr | "filter" expr | "sort" sort_fields
| "take" expr | "limit" expr | "skip" expr
| "select" select_items | "distinct"
| [ modifier ] "join" IDENT [ IDENT ] [ "on" expr ]
| "group" expr | "set" expr | "values" "{" expr "}"
| "assert" expr | "returning" expr
| "require" ( "found" | "confirm_full_table" )
| ( "union" | "intersect" | "except" ) expr ;

For additional documentation see: