P2.1: domain structs + boundary validation

Add the core distribution domain model under src/domain/ (App, Release,
Artifact, Channel, AuditEvent + Platform/Visibility/ValidationStatus/
RolloutPolicy enums) and a boundary validator that returns one distinct
typed ValidationErr per failure class (BadSlug, EmptyVersion, BadVersion,
BadChannelName, UnknownPlatform, MissingField, BadRollout).

Pure sx, depends only on modules/std.sx; lookups left as linear scans over
List (no HashMap). tests/domain_validate.sx asserts valid App/Release/
Artifact/Channel are accepted and each invalid case is rejected with the
exact expected error tag.
This commit is contained in:
agra
2026-06-05 23:02:41 +03:00
parent 331a3e06dc
commit c3897e3508
8 changed files with 449 additions and 0 deletions

31
src/domain/app.sx Normal file
View File

@@ -0,0 +1,31 @@
#import "modules/std.sx";
#import "platform.sx";
// Who can see an app in listings: `private` = owner only, `unlisted` =
// reachable by direct link, `public` = listed.
Visibility :: enum u8 {
private;
unlisted;
public;
}
// A reverse-DNS bundle identifier (e.g. "co.acme.app") for one platform.
// An app carries at most one per platform; kept as a flat list scanned
// linearly (no HashMap yet).
BundleId :: struct {
platform: Platform;
value: string;
}
// A distributable application. `slug` is the URL-safe handle; `id` is the
// opaque primary key. Timestamps are unix epoch seconds.
App :: struct {
id: string;
slug: string;
display_name: string;
bundle_ids: List(BundleId);
owner: string;
visibility: Visibility = .private;
created_at: s64;
updated_at: s64;
}

26
src/domain/artifact.sx Normal file
View File

@@ -0,0 +1,26 @@
#import "modules/std.sx";
#import "platform.sx";
// Where an artifact sits in the validation pipeline: artifacts enter
// `pending`, then validation moves them to `valid` or `invalid`.
ValidationStatus :: enum u8 {
pending;
valid;
invalid;
}
// A single uploaded binary belonging to a release, for one platform.
// `sha256` is the lowercase hex digest; `storage_key` locates the bytes
// in the blob store.
Artifact :: struct {
id: string;
app_id: string;
release_id: string;
platform: Platform;
filename: string;
content_type: string;
size_bytes: s64;
sha256: string;
storage_key: string;
validation_status: ValidationStatus = .pending;
}

15
src/domain/audit.sx Normal file
View File

@@ -0,0 +1,15 @@
#import "modules/std.sx";
// An append-only record of a mutation. `actor` is the user or service that
// performed `action` on the target identified by `target_type` +
// `target_id`. `metadata` is an opaque string for now (structured JSON
// arrives with std/json in a later step). `created_at` is unix epoch seconds.
AuditEvent :: struct {
id: string;
actor: string;
action: string;
target_type: string;
target_id: string;
metadata: string;
created_at: s64;
}

19
src/domain/channel.sx Normal file
View File

@@ -0,0 +1,19 @@
#import "modules/std.sx";
// How a channel rolls a release out: `manual` is all-or-nothing once
// published; `percentage` stages exposure by `rollout_percent` (0..100).
RolloutPolicy :: enum u8 {
manual;
percentage;
}
// A named publishing track for an app (e.g. "stable", "beta"), pointing at
// the release currently serving traffic. `rollout_percent` is meaningful
// only under the `percentage` policy.
Channel :: struct {
app_id: string;
name: string;
current_release_id: string;
policy: RolloutPolicy = .manual;
rollout_percent: s64 = 100;
}

11
src/domain/platform.sx Normal file
View File

@@ -0,0 +1,11 @@
#import "modules/std.sx";
// Target platforms a release can ship to. `android_apk` names the APK
// artifact form specifically; the remaining variants are one per OS.
Platform :: enum u8 {
ios;
android_apk;
macos;
linux;
windows;
}

16
src/domain/release.sx Normal file
View File

@@ -0,0 +1,16 @@
#import "modules/std.sx";
// A versioned build of an app, published to one channel. `version` is the
// human semver string; `build` is the monotonic build number. `channel`
// names the Channel it targets. Timestamps are unix epoch seconds.
Release :: struct {
id: string;
app_id: string;
version: string;
build: s64;
channel: string;
notes: string;
created_by: string;
created_at: s64;
updated_at: s64;
}

153
src/domain/validate.sx Normal file
View File

@@ -0,0 +1,153 @@
#import "modules/std.sx";
#import "platform.sx";
#import "app.sx";
#import "release.sx";
#import "artifact.sx";
#import "channel.sx";
// Typed failures from boundary validation. One distinct tag per failure
// class so callers (and, later, the CLI) can surface a precise message
// instead of a bare bool that drops the reason.
ValidationErr :: error {
MissingField, // a required string field was empty
BadSlug, // slug broke the [a-z0-9-] / hyphen-position rules
EmptyVersion, // version string was empty
BadVersion, // version non-empty but not MAJOR.MINOR.PATCH(-suffix)
BadChannelName, // channel name empty or outside [a-z0-9-]
UnknownPlatform, // platform id did not name a Platform variant
BadRollout, // rollout_percent outside 0..100
}
// ── Character classes (ASCII byte codes) ────────────────────────────────
is_lower :: (c: u8) -> bool { return c >= 97 and c <= 122; } // 'a'..'z'
is_upper :: (c: u8) -> bool { return c >= 65 and c <= 90; } // 'A'..'Z'
is_digit :: (c: u8) -> bool { return c >= 48 and c <= 57; } // '0'..'9'
// ── Field validators ────────────────────────────────────────────────────
// slug: non-empty; every byte in [a-z0-9-]; no leading or trailing hyphen;
// no two consecutive hyphens. So "acme-app" passes; "", "-x", "x-", "a--b",
// and "App" are all rejected.
validate_slug :: (s: string) -> !ValidationErr {
if s.len == 0 { raise error.BadSlug; }
if s[0] == 45 { raise error.BadSlug; } // 45='-' leading
if s[s.len - 1] == 45 { raise error.BadSlug; } // trailing
i := 0;
while i < s.len {
c := s[i];
if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadSlug; }
if c == 45 and i > 0 {
if s[i - 1] == 45 { raise error.BadSlug; } // double hyphen
}
i += 1;
}
return;
}
// version: MAJOR.MINOR.PATCH, each component a run of >=1 digit, optionally
// followed by "-<suffix>" where suffix is a non-empty run of [A-Za-z0-9.-]
// (e.g. "1.0.0", "2.10.3-beta.1"). Empty -> EmptyVersion; any other
// deviation -> BadVersion.
validate_version :: (s: string) -> !ValidationErr {
if s.len == 0 { raise error.EmptyVersion; }
i := 0;
comp := 0;
while comp < 3 {
start := i;
while i < s.len {
if !is_digit(s[i]) { break; }
i += 1;
}
if i == start { raise error.BadVersion; } // no digits in component
if comp < 2 {
if i >= s.len { raise error.BadVersion; } // missing '.' separator
if s[i] != 46 { raise error.BadVersion; } // 46='.'
i += 1;
}
comp += 1;
}
if i < s.len {
if s[i] != 45 { raise error.BadVersion; } // only a '-' suffix allowed
i += 1;
if i >= s.len { raise error.BadVersion; } // empty suffix
while i < s.len {
c := s[i];
if !(is_lower(c) or is_upper(c) or is_digit(c) or c == 45 or c == 46) {
raise error.BadVersion;
}
i += 1;
}
}
return;
}
// channel name: non-empty; every byte in [a-z0-9-]. Looser than slug —
// hyphen position is unconstrained (e.g. "stable", "beta-2").
validate_channel_name :: (s: string) -> !ValidationErr {
if s.len == 0 { raise error.BadChannelName; }
i := 0;
while i < s.len {
c := s[i];
if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadChannelName; }
i += 1;
}
return;
}
// Parse a platform id into the Platform enum. Accepted ids are exactly the
// variant names; anything else -> UnknownPlatform.
parse_platform :: (s: string) -> (Platform, !ValidationErr) {
if s == "ios" { return .ios; }
if s == "android_apk" { return .android_apk; }
if s == "macos" { return .macos; }
if s == "linux" { return .linux; }
if s == "windows" { return .windows; }
raise error.UnknownPlatform;
}
// ── Aggregate validators (required-field presence + field rules) ─────────
// App requires id, slug, display_name, owner; slug must also pass
// validate_slug. Presence is checked first, so an empty slug surfaces as
// MissingField and a present-but-malformed slug as BadSlug.
validate_app :: (a: App) -> !ValidationErr {
if a.id.len == 0 { raise error.MissingField; }
if a.slug.len == 0 { raise error.MissingField; }
if a.display_name.len == 0 { raise error.MissingField; }
if a.owner.len == 0 { raise error.MissingField; }
try validate_slug(a.slug);
return;
}
// Release requires id, app_id, created_by; version goes through
// validate_version (empty -> EmptyVersion) and channel through
// validate_channel_name.
validate_release :: (r: Release) -> !ValidationErr {
if r.id.len == 0 { raise error.MissingField; }
if r.app_id.len == 0 { raise error.MissingField; }
if r.created_by.len == 0 { raise error.MissingField; }
try validate_version(r.version);
try validate_channel_name(r.channel);
return;
}
// Artifact requires id, app_id, release_id, filename, sha256, storage_key.
// platform and validation_status are enums, hence always well-formed.
validate_artifact :: (a: Artifact) -> !ValidationErr {
if a.id.len == 0 { raise error.MissingField; }
if a.app_id.len == 0 { raise error.MissingField; }
if a.release_id.len == 0 { raise error.MissingField; }
if a.filename.len == 0 { raise error.MissingField; }
if a.sha256.len == 0 { raise error.MissingField; }
if a.storage_key.len == 0 { raise error.MissingField; }
return;
}
// Channel requires app_id; name goes through validate_channel_name and
// rollout_percent must be in 0..100.
validate_channel :: (c: Channel) -> !ValidationErr {
if c.app_id.len == 0 { raise error.MissingField; }
try validate_channel_name(c.name);
if c.rollout_percent < 0 or c.rollout_percent > 100 { raise error.BadRollout; }
return;
}