-
Notifications
You must be signed in to change notification settings - Fork 0
Declaring Storage
Everything Groundwork does starts from a StorageManifest: a provider-neutral declaration of the
storage units your module owns, the logical indexes over them, and the bounded queries and
mutations your application will execute. The manifest is the whole contract — providers certify it
before traffic, the schema tooling deploys it, and nothing outside it is executable.
var manifest = new StorageManifest(
new StorageManifestIdentity("support-tickets"), // stable manifest identity
new StorageManifestOwner("sample.support"), // owning module
new StorageManifestVersion("1.0.0"), // schema version
[ /* storage units */ ],
new HashSet<string> { "schema-history", "optimistic-concurrency" }, // required facilities
[]);Manifest identity, owner, and version travel into schema history and fingerprints. Note that the schema diff is driven by durable semantic fingerprints, not the version string alone — adding an index without bumping the version is still detected as pending work (see Schema-Evolution).
A StorageUnit declares one document kind and its policies:
StorageUnit.Create(
new StorageUnitIdentity("supportTicket"),
"Support ticket",
StorageIntent.PortableDocument(),
LifecyclePolicy.Mutable,
IdentityPolicy.StringId(),
TenancyPolicy.Global,
ConcurrencyPolicy.Optimistic(),
SerializationPolicy.Json(),
new StorageUnitPhysicalStorage(...));-
StorageIntentdeclares the provider capabilities the unit requires; provider fit is computed from those declared requirements, never from author self-declaration.StorageIntent.PortableDocument()is the default document/table contract;StorageIntent.Operational(rationale, descriptor, requirements)declaresCapabilityIdrequirements plus the rationale when correctness depends on more — atomic commit across units, concurrency evidence, or custom semantics contributed by external modules such as the Inbox sample (see Samples). -
TenancyPolicyisGlobalorScoped— there is no ambient or payload-derived tenancy. A scoped unit is only reachable through a session opened with a matchingStorageScope; see Storage-Scopes. -
ConcurrencyPolicy.Optimistic()enables version-gated saves and deletes viaExpectedVersion. -
SerializationPolicy.Json()— documents are canonical JSON envelopes; the JSON stays authoritative in every physical form.
StorageUnitPhysicalStorage fixes how the unit is provisioned:
new StorageUnitPhysicalStorage(
StorageUnitProvisioningMode.Declared,
PhysicalStoragePolicy.Default(), // or PhysicalStoragePolicy.Explicit(definition)
logicalIndexes: [...],
boundedQueries: [...],
boundedMutations: [...]); // optionalPhysical intent uses exactly three provider-neutral forms (ADR 0003):
- Shared documents — dynamic/runtime-defined units share a provider-level documents structure with linked index storage.
- Dedicated document table — a declared unit without scale-bearing projected-field demand gets its own table with the standard envelope and canonical JSON.
- Physical entity table — a declared unit whose bounded queries mark stable non-envelope paths as scale-bearing gets envelope, canonical JSON, and typed projected columns in one row.
All three retain the standard envelope and authoritative canonical JSON; projected columns are rebuildable derivatives, never a second source of truth. The form is a manifest and planner decision, not a caller-visible query choice — callers always use the same store and query contract.
PhysicalStoragePolicy.Default() synthesizes the projected columns and physical indexes your
declarations demand. When you need to state the physical shape yourself — exact columns, index key
order, identity tie-breaks — use PhysicalStoragePolicy.Explicit with a
PhysicalTableDefinition; the
sample manifest
declares explicit physical-entity tables that match what the default policy would synthesize.
A LogicalIndexDeclaration names an index over stable serialized content paths:
new LogicalIndexDeclaration(
"by-ticket-number",
[new IndexField("ticketNumber")],
IndexValueKind.Keyword,
isUnique: true,
MissingValueBehavior.Excluded,
length: 128)-
IndexValueKindsupplies the default value semantics for the whole index (Keyword,String,Number, date-time, …). In a heterogeneous compound index an individualIndexField.ValueKindmay override the default, making differences such as keyword identity plus date-time ordering explicit. -
isUnique— uniqueness is enforced within the storage scope: the same unique value can exist in independent scopes because scoped physical unique indexes include the scope key. Adding a unique index over pre-existing duplicate values fails materialization loudly; the data must be reconciled first. -
MissingValueBehaviordecides what happens to documents with no value for a nullable keyed column.Excludedomits those rows from the index (a filtered/partial index on every provider) and gives null-distinct uniqueness — the constraint applies only to rows that have a value.IncludedAsNullkeeps them. A unique index that keeps rows without a value is refused at route compilation (GW-ROUTE-007) because providers genuinely disagree about whether two such rows collide.
IndexValueKind.String/Keyword fields synthesize string projected columns. Unbounded string
keys are valid on PostgreSQL, SQLite, and MongoDB, but SQL Server's index keys are sized and
reject unbounded key columns. Declare the maximum key length — a count of UTF-16 code units — on
the field or as a declaration-level default
(ADR 0008):
new LogicalIndexDeclaration("by-status", [new IndexField("status")],
IndexValueKind.Keyword, isUnique: false, MissingValueBehavior.Excluded,
length: 128)Validation (GW-PHYSICAL-039): a length is at least 1, only String/Keyword fields may declare
one, and all scale-bearing demand for one path within a unit must agree on a single declared
length. An absent length stays unbounded — providers that require a bound keep rejecting it at
route compilation.
IndexValueKind.Number fields map to portable decimal columns, which require an explicit
precision and scale — the resolver deliberately refuses to invent them. Declare the pair on the
field or as a declaration-level default
(ADR 0007):
precision 1–28 (the provider-portable envelope set by SQL Server index-key sizing; SQLite narrows
it to 1–18), scale 0–precision, both components declared together. Validation is
GW-PHYSICAL-038; numeric scale-bearing demand with no declared shape fails with
GW-PHYSICAL-018.
One BoundedQueryDeclaration per read the application performs:
new BoundedQueryDeclaration(
"list-by-status", // bounded-query identity, used by runtime DocumentQuery
"by-status", // the logical index it addresses
new HashSet<PortableQueryOperation> { PortableQueryOperation.Equal },
QuerySortSupport.Ascending,
QueryPagingSupport.Offset, // or Cursor for keyset paging, or None
BoundedQueryExecutionClass.ScaleBearing,
supportsTotalCount: true)A declaration owns its whole shape up front:
-
Operators — equality, inequality, membership (
In), prefix/declared substring (StartsWith/Contains), and ranges. Operators apply per value kind: substring and prefix only to string/keyword values; ranges to string/keyword, number, and date-time values. - Predicate fields — explicit paths for compound-prefix validation. An equality predicate prefix may be followed by a sort suffix; runtime requests using the suffix must supply exactly one standalone equality comparison for every skipped prefix field.
- Sort and paging — per-path compound sort directions; offset or cursor/keyset paging. Every ordered plan appends the document identity as an ascending total-order tie-breaker.
- Result operations — documents, count, any, and first.
-
Execution class —
OrdinaryorScaleBearing. Marking a queryScaleBearingmakes its referenced stable content paths binding demand: they must be served by typed projected columns with one physical index per referenced logical index, and the provider must certify an indexed server-side plan. A provider that cannot is rejected at startup — never scanned around. -
Residual predicate fields —
BoundedQueryResidualPredicateFielddeclares an optional typed server-side filter that does not add its path to the index key or predicate-prefix evidence. Residual paths remain closed and typed; a scale-bearing residual path must be physically available on the indexed primary route (use a physical entity table).
Omitting predicateFields filters the first path of the logical index. An explicit
predicateFields: [] declares a truly unfiltered bounded route and must also declare its
provider-applied sortFields; unfiltered declarations are never valid bounded-mutation
predicates.
Bounded document mutations are named lifecycle operations over existing bounded-query plans — not a general update/delete language. The manifest fixes both the predicate shape and the only allowed effect; a runtime caller supplies an operation identity and values for the declared predicates:
boundedMutations:
[
new BoundedMutationDeclaration(
"revoke-pending",
"by-authorization-and-status",
BoundedMutationAction.Transition("status", ["pending"], "revoked")),
new BoundedMutationDeclaration(
"revoke-authorization",
"by-authorization",
BoundedMutationAction.Assign("status", "revoked")),
new BoundedMutationDeclaration(
"prune-expired",
"by-authorization-and-expiration",
BoundedMutationAction.Delete())
]-
Transitionchanges one content path only from its finite manifest-declared source values to its manifest-declared target. -
Assignsets one manifest-declared scalar content projection to its manifest-declared target for every selected row — already-target, null, missing, and other source values are all processed. -
Deleteremoves the selected documents.
A mutation is executable only when its predicate is scale-bearing and has a physical index.
Envelope and linked-relationship fields are immutable through value actions. Execution is
transactional, ledgered, and idempotent: an identical retry returns Replayed with the original
count, and reusing an operation identity for a different request throws
BoundedMutationOperationConflictException. Ordinary point Save/Delete/Load by document
identity are not mutations — they need no declaration beyond the unit itself. See
bounded document mutations
for the full execution contract.
Relationship guards.
RequireNoReferencesandRequireRelatedTargetNotEqualexist as closed manifest declarations, but every shipped provider currently rejects any manifest containing a relationship declaration or guard withGW-RELATIONSHIP-012before schema or document I/O. Do not declare them yet.
Applications that also use immutable diagnostic records compose those stream definitions through
DiagnosticRecordDeploymentManifest; streams are deployed alongside document units through the
same schema tooling but are a separate contract family. See Diagnostic-Records.
- Getting-Started — a complete minimal manifest in context.
-
Querying — how declarations become runtime
DocumentQueryrequests. - Schema-Evolution — how a changed manifest becomes pending schema work.
- Executable storage routes and bounded physical query plans — the deep dives behind compilation and certification.
This wiki is generated from
docs/wiki/ in the main
repository and republished on every push to main — do not edit pages here; changes made
directly in the wiki will be overwritten. To propose a change, open a pull request against
docs/wiki/.
Using Groundwork
- Getting-Started
- Declaring-Storage
- Opening-Stores
- Querying
- Transactions-and-Unit-of-Work
- Storage-Scopes
- Schema-Evolution
- Identity-Generators
Providers
Beyond documents
Reference