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;
|
||||
}
|
||||
178
tests/domain_validate.sx
Normal file
178
tests/domain_validate.sx
Normal file
@@ -0,0 +1,178 @@
|
||||
// Acceptance for P2.1 — the domain boundary validator. Constructs valid
|
||||
// App/Release/Artifact/Channel and asserts they are accepted, then mutates
|
||||
// each into one invalid form and asserts it is rejected with the SPECIFIC
|
||||
// typed ValidationErr (not merely "some failure"). Prints a pass/fail line
|
||||
// per case and exits non-zero if any case behaves unexpectedly.
|
||||
#import "modules/std.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
#import "../src/domain/app.sx";
|
||||
#import "../src/domain/release.sx";
|
||||
#import "../src/domain/artifact.sx";
|
||||
#import "../src/domain/channel.sx";
|
||||
#import "../src/domain/validate.sx";
|
||||
|
||||
// ── Valid fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
valid_app :: () -> App {
|
||||
a : App = .{
|
||||
id = "app_01",
|
||||
slug = "acme-app",
|
||||
display_name = "Acme App",
|
||||
owner = "user_01",
|
||||
visibility = .public,
|
||||
created_at = 1700000000,
|
||||
updated_at = 1700000000,
|
||||
};
|
||||
a.bundle_ids.append(BundleId.{ platform = .ios, value = "co.acme.app" });
|
||||
return a;
|
||||
}
|
||||
|
||||
valid_release :: () -> Release {
|
||||
return Release.{
|
||||
id = "rel_01",
|
||||
app_id = "app_01",
|
||||
version = "1.2.3-beta.1",
|
||||
build = 42,
|
||||
channel = "stable",
|
||||
notes = "first cut",
|
||||
created_by = "user_01",
|
||||
created_at = 1700000000,
|
||||
updated_at = 1700000000,
|
||||
};
|
||||
}
|
||||
|
||||
valid_artifact :: () -> Artifact {
|
||||
return Artifact.{
|
||||
id = "art_01",
|
||||
app_id = "app_01",
|
||||
release_id = "rel_01",
|
||||
platform = .android_apk,
|
||||
filename = "acme.apk",
|
||||
content_type = "application/vnd.android.package-archive",
|
||||
size_bytes = 10485760,
|
||||
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
storage_key = "apps/app_01/rel_01/acme.apk",
|
||||
validation_status = .valid,
|
||||
};
|
||||
}
|
||||
|
||||
valid_channel :: () -> Channel {
|
||||
return Channel.{
|
||||
app_id = "app_01",
|
||||
name = "stable",
|
||||
current_release_id = "rel_01",
|
||||
policy = .percentage,
|
||||
rollout_percent = 25,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
check_accepts_app :: () -> bool {
|
||||
a := valid_app();
|
||||
ok := true;
|
||||
validate_app(a) catch { ok = false; };
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_accepts_release :: () -> bool {
|
||||
r := valid_release();
|
||||
ok := true;
|
||||
validate_release(r) catch { ok = false; };
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_accepts_artifact :: () -> bool {
|
||||
a := valid_artifact();
|
||||
ok := true;
|
||||
validate_artifact(a) catch { ok = false; };
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_accepts_channel :: () -> bool {
|
||||
c := valid_channel();
|
||||
ok := true;
|
||||
validate_channel(c) catch { ok = false; };
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_accepts_platform :: () -> bool {
|
||||
p, e := parse_platform("android_apk");
|
||||
ok := false;
|
||||
if !e { ok = (p == .android_apk); }
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_rejects_bad_slug :: () -> bool {
|
||||
a := valid_app();
|
||||
a.slug = "Bad_Slug"; // uppercase + underscore
|
||||
matched := false;
|
||||
validate_app(a) catch e { matched = (e == error.BadSlug); };
|
||||
return matched;
|
||||
}
|
||||
|
||||
check_rejects_empty_version :: () -> bool {
|
||||
r := valid_release();
|
||||
r.version = "";
|
||||
matched := false;
|
||||
validate_release(r) catch e { matched = (e == error.EmptyVersion); };
|
||||
return matched;
|
||||
}
|
||||
|
||||
check_rejects_bad_version :: () -> bool {
|
||||
r := valid_release();
|
||||
r.version = "1.2"; // missing PATCH component
|
||||
matched := false;
|
||||
validate_release(r) catch e { matched = (e == error.BadVersion); };
|
||||
return matched;
|
||||
}
|
||||
|
||||
check_rejects_unknown_platform :: () -> bool {
|
||||
_, e := parse_platform("plan9");
|
||||
return e == error.UnknownPlatform;
|
||||
}
|
||||
|
||||
check_rejects_bad_channel :: () -> bool {
|
||||
c := valid_channel();
|
||||
c.name = "Bad Channel"; // space + uppercase
|
||||
matched := false;
|
||||
validate_channel(c) catch e { matched = (e == error.BadChannelName); };
|
||||
return matched;
|
||||
}
|
||||
|
||||
check_rejects_missing_field :: () -> bool {
|
||||
a := valid_artifact();
|
||||
a.sha256 = ""; // required field cleared
|
||||
matched := false;
|
||||
validate_artifact(a) catch e { matched = (e == error.MissingField); };
|
||||
return matched;
|
||||
}
|
||||
|
||||
run_case :: (label: string, ok: bool) -> s32 {
|
||||
if ok { print(" PASS {}\n", label); return 0; }
|
||||
print(" FAIL {}\n", label);
|
||||
return 1;
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
failures : s32 = 0;
|
||||
failures += run_case("accept: valid app", check_accepts_app());
|
||||
failures += run_case("accept: valid release", check_accepts_release());
|
||||
failures += run_case("accept: valid artifact", check_accepts_artifact());
|
||||
failures += run_case("accept: valid channel", check_accepts_channel());
|
||||
failures += run_case("accept: platform 'android_apk'", check_accepts_platform());
|
||||
failures += run_case("reject: bad slug -> BadSlug", check_rejects_bad_slug());
|
||||
failures += run_case("reject: empty version -> EmptyVersion", check_rejects_empty_version());
|
||||
failures += run_case("reject: bad version -> BadVersion", check_rejects_bad_version());
|
||||
failures += run_case("reject: unknown platform -> UnknownPlatform", check_rejects_unknown_platform());
|
||||
failures += run_case("reject: bad channel -> BadChannelName", check_rejects_bad_channel());
|
||||
failures += run_case("reject: missing field -> MissingField", check_rejects_missing_field());
|
||||
|
||||
print("------------------------------------------------\n");
|
||||
if failures == 0 {
|
||||
print("domain_validate: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
print("domain_validate: {} CASE(S) FAILED\n", failures);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user