diff --git a/src/domain/app.sx b/src/domain/app.sx new file mode 100644 index 0000000..bc8e0f6 --- /dev/null +++ b/src/domain/app.sx @@ -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; +} diff --git a/src/domain/artifact.sx b/src/domain/artifact.sx new file mode 100644 index 0000000..c79f257 --- /dev/null +++ b/src/domain/artifact.sx @@ -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; +} diff --git a/src/domain/audit.sx b/src/domain/audit.sx new file mode 100644 index 0000000..89cfaec --- /dev/null +++ b/src/domain/audit.sx @@ -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; +} diff --git a/src/domain/channel.sx b/src/domain/channel.sx new file mode 100644 index 0000000..cbe1e44 --- /dev/null +++ b/src/domain/channel.sx @@ -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; +} diff --git a/src/domain/platform.sx b/src/domain/platform.sx new file mode 100644 index 0000000..33e51df --- /dev/null +++ b/src/domain/platform.sx @@ -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; +} diff --git a/src/domain/release.sx b/src/domain/release.sx new file mode 100644 index 0000000..4942401 --- /dev/null +++ b/src/domain/release.sx @@ -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; +} diff --git a/src/domain/validate.sx b/src/domain/validate.sx new file mode 100644 index 0000000..3a1f5d7 --- /dev/null +++ b/src/domain/validate.sx @@ -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 "-" 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; +} diff --git a/tests/domain_validate.sx b/tests/domain_validate.sx new file mode 100644 index 0000000..f444c7c --- /dev/null +++ b/tests/domain_validate.sx @@ -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; +}