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:
31
src/domain/app.sx
Normal file
31
src/domain/app.sx
Normal 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
26
src/domain/artifact.sx
Normal 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
15
src/domain/audit.sx
Normal 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
19
src/domain/channel.sx
Normal 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
11
src/domain/platform.sx
Normal 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
16
src/domain/release.sx
Normal 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
153
src/domain/validate.sx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user