wf-nightly-digest
wf-nightly-digest — the SCHEDULED trigger, end to end.
6 source files1 test file

Get it
// not one of the apps the toolchain ships: this one lives in the // repository. Clone it, then: $ cd demo/wf-nightly-digest $ osy launch
The app
app.osy6 lines
// wf-nightly-digest — the SCHEDULED trigger, end to end. app WfNightlyDigest { model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/digest.osy45 lines
// The nightly digest itself — a workflow whose runs nobody starts. enum DigestStatus { Pending, Sent, Empty } /// One night's digest. A row of this IS an occurrence — the schedule creates it, and everything below reacts to it. entity DigestRun { /// The run's state. No default: the workflow's `Initial` is this field's value from the moment the row exists. DigestStatus State; /// How many events the digest covered. Zero is a real answer and gets its own terminal. int Covered; /// When the digest actually went out — the thing an operator looks at to answer "did last night run?". DateTime? SentAt; security { allow read, create, update, delete when IsAuthenticated; } } /// Something worth telling people about. Ordinary app data; the digest sweeps whatever is unsent. entity Notice { [Required, MaxLength(200)] string Text; bool Sent; security { allow read, create, update, delete when IsAuthenticated; } } workflow NightlyDigest { Tracks = DigestRun.State; Autostart = true; // ← the seam. The schedule creates the ROW; this is what turns a row into a run. Initial = Pending; state Pending { enter { var unsent = Notice.Where(n => !n.Sent).ToList(); this.Item.Covered = unsent.Count; if (unsent.Count == 0) { goto Empty; } foreach (var n in unsent) { n.Sent = true; } this.Item.SentAt = DateTime.UtcNow; goto Sent; } } terminal success Sent { } terminal success Empty { } }
model/identity.osy57 lines
// The identity tier, self-contained. The same shape every other workflow demo uses — a [Principal] account, a [Role] // enum, and a RoleGrant table recognised by its SHAPE — declared here rather than shared, so this demo compiles on // its own the way a downloader's copy of it would. [Principal] entity Account { [Required, MaxLength(200)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string PasswordHash; security { deny read PasswordHash when !IsAuthenticator; allow read when IsAuthenticated; // …and by the AUTH FLOW, which is not "authenticated": the armed principal bears the Authenticator role and has // NO user, so `IsAuthenticated` is false for it. Without this line `Login`'s lookup matched no rows and a CORRECT // password was refused exactly like a wrong one, while signing UP kept working — which is what hid it. allow read when IsAuthenticator; allow create when IsAuthenticator; allow update, delete when IsAdmin; } } [Role] enum AppRole { Authenticator, Member, Admin } entity RoleGrant { [Required] Account Grantee; [Required] AppRole Level = AppRole.Member; security { allow read when IsAuthenticated; allow create when IsAuthenticator; // …the signup, for the first grant allow create, update, delete when IsAdmin; // all three verbs, or the weakest is the way in } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsAdmin => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Admin); [AuthMethod] string Signup(string email, string password) { var a = new Account { Name = email, Email = email, PasswordHash = Security.HashPassword(password) }; var grant = new RoleGrant { Grantee = a, Level = AppRole.Member }; return Security.IssueJwt(a.Id, a.Email); } [AuthMethod] string Login(string email, string password) { var a = Account.Where(x => x.Email == email).FirstOrDefault(); if (a == null) { Security.VerifyPassword(password); return ""; } if (Security.VerifyPassword(password, a.PasswordHash)) { return Security.IssueJwt(a.Id, a.Email); } return ""; } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, };
model/scheduling_security.osy5 lines
// `Schedule` and its children are BASELINE-shaped and APP-OWNED: the platform ships what a schedule IS and drives the // rows, and this app owns them — so under the security model it has to say out loud who may reach them. Nothing is // readable or writable until it does. partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }
model/seed.osy27 lines
// The schedule — the only thing in this app that makes the digest nightly. void SeedNightlyDigest() { if (Osyrin.Scheduling.Schedule.Any()) { return; } // idempotent — a second call mints no second schedule var nightly = new Osyrin.Scheduling.Schedule { Name = "Nightly digest", Template = new DigestRun { }, Zone = "Europe/Stockholm", EffectiveFrom = DateTime.UtcNow, Overlap = Osyrin.Scheduling.ScheduleOverlap.Skip }; var everyNight = new Osyrin.Scheduling.ScheduleRule { Schedule = nightly, Every = Osyrin.Scheduling.ScheduleFrequency.Day, Interval = 1 }; new Osyrin.Scheduling.ScheduleRuleTime { Rule = everyNight, At = TimeSpan.FromHours(2) }; } /// A couple of notices, so a first digest has something to be a digest OF. void SeedNotices() { new Notice { Text = "Quarterly maintenance window moved to Sunday", Sent = false }; new Notice { Text = "New export format available", Sent = false }; }
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a // byte-identical `model/theme.osy`, and that test is what keeps them identical. // // ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point. // The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`, // `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own // `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment // one of them reached for a kit control, that control painted itself indigo next to the demo's // blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that // existed only in the theme file. // // So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name // the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it // does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything. // // Excluded by design — these five own their look and must NOT adopt this file: arcade, ember, // motion, gestures (each demonstrates a visual world) and Apps/recall-osy. theme Demo { Colors { // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a // considered app rather than an unstyled one, and the two should be distinguishable on sight. // It is the one saturated colour on the page; everything else is warm neutral, which is what // keeps "distinct" from turning into "loud". Primary = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6")); OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F"); Bg = Modes.Of(light: "#FAF9F7", dark: "#101317"); // the page — a touch warm, so cards read as lifted Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22"); // a card, a row, a lane Muted = Modes.Of(light: "#EFEEEA", dark: "#1E242B"); // an inline notice, a disabled field Border = Modes.Of(light: "#E4E2DC", dark: "#28303A"); OnBg = Modes.Of(light: "#14181D", dark: "#E7EBF0"); OnSurface = Modes.Of(light: "#14181D", dark: "#E7EBF0"); TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3"); // labels, timestamps, captions TextMuted = Modes.Of(light: "#7C848E", dark: "#7E8894"); // the quietest text on the page Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A")); Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343")); Danger = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A")); } // Names the kit does not have, so these are extensions rather than second spellings. Radius { Control = "8px"; Card = "12px"; } // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans` // shadows the kit's own token — so every kit control picks it up with no page edit at all. // Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font // add`), because a webfont a page merely NAMES is a webfont that silently falls back. // Only the faces the app SHIPS are named here — Mono is left at the kit default because no // demo renders code, and naming an unshipped family is a silent fallback. // Geist for the UI: a neutral grotesque with real character at small sizes, which is where a // dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page // title. The pairing is the whole look — one voice for reading, one for announcing. Font { Sans = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif"; Serif = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif"; } FontSize { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; } FontWeight { Regular = 400; Medium = 500; Semibold = 600; } // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. }