1use crate::schema::Resource;
25
26pub const ORGANIZATION_TOML: &str = r#"
29[resource]
30name = "organization"
31scope = "global"
32timestamps = true
33
34[permissions]
35list = "member" # organisations you belong to
36read = "member"
37create = "authenticated" # anyone may start one (and becomes its admin)
38update = "role:admin" # an admin *of that organisation*
39delete = "role:admin"
40
41[fields.name]
42type = "string"
43required = true
44
45[fields.slug]
46type = "string"
47unique = true
48
49# A logo, as a URL a browser can fetch. Nothing sets it — an organisation is not
50# handed to us by an identity provider the way a person is — so it is here for
51# an app to fill and for every interface to read: the dashboard's workspace
52# switcher shows it in place of the initials it would otherwise draw.
53#
54# A `file` field, so the dashboard offers an upload into `[storage]` as well as
55# a URL box. What is stored is a string either way — an uploaded logo is a
56# relative link this server answers, a pasted one is whatever was pasted.
57[fields.avatar_url]
58type = "file"
59max_length = 1024
60"#;
61
62pub const MEMBERSHIP_TOML: &str = r#"
65[resource]
66name = "membership"
67scope = "organization"
68timestamps = true
69
70[permissions]
71list = "member" # members can see who else is in the org
72read = "member"
73create = "role:admin" # admins add members
74update = "role:admin"
75delete = "role:admin"
76
77# Built-in function: lets `create` name the person by `email` instead of by
78# `user_id`, and refuses a duplicate membership. The lookup has to happen here
79# because `user` is only readable by people you already share an org with.
80[hooks]
81before_create = "apiplant_organization_join"
82
83[fields.user_id]
84type = "reference"
85references = "user"
86required = true
87on_delete = "cascade" # a deleted account takes its memberships with it
88
89[fields.organization_id]
90type = "reference"
91references = "organization"
92required = true
93
94[fields.role]
95type = "string" # the member's *primary* role here, e.g. "admin".
96 # Further roles are `membership_role` rows; a
97 # `role:` permission is checked against all of them.
98"#;
99
100pub const MEMBERSHIP_ROLE_TOML: &str = r#"
112[resource]
113name = "membership_role"
114scope = "organization"
115timestamps = true
116
117[permissions]
118list = "member" # members can see who holds what
119read = "member"
120create = "role:admin" # admins grant roles
121update = "role:admin"
122delete = "role:admin"
123
124[fields.membership_id]
125type = "reference"
126references = "membership"
127required = true
128on_delete = "cascade" # a removed member takes their roles with them
129
130[fields.organization_id]
131type = "reference"
132references = "organization"
133required = true
134
135[fields.role]
136type = "string"
137required = true
138"#;
139
140pub const USER_TOML: &str = r#"
143[resource]
144name = "user"
145scope = "global"
146timestamps = true
147
148[permissions]
149list = "member" # people you share an organisation with
150read = "member"
151create = "public" # registration
152update = "owner"
153delete = "private"
154
155[auth]
156identity_field = "email"
157password_field = "password_hash"
158oauth_providers = []
159
160[fields.email]
161type = "string"
162required = true
163unique = true
164max_length = 320
165
166[fields.password_hash]
167type = "string"
168hidden = true
169
170# When the address was confirmed. Null means unconfirmed — which only *stops*
171# anyone when `[auth] require_email_verification` is on, so an app with no
172# mailer carries the column and never looks at it.
173[fields.email_verified_at]
174type = "timestamp"
175
176[fields.email_verified_at.admin]
177visible = false
178
179# What to call somebody, and what to show beside their name.
180#
181# Both are ordinary nullable columns nothing requires — an app that has no use
182# for either can leave them empty or drop them by replacing this model. They are
183# here because they are what almost every app wants and what every identity
184# provider hands over: a sign-in through [`[oauth]`](crate::config::OAuthConfig)
185# fills them in, so an account that arrives that way arrives with a name and a
186# picture rather than an email address and a blank.
187#
188# The picture is a `file` field: a provider fills it with the URL it gave us,
189# and somebody changing it in the dashboard uploads one into `[storage]` or
190# types a URL of their own. Both are the same string in the same column.
191[fields.display_name]
192type = "string"
193
194[fields.avatar_url]
195type = "file"
196max_length = 1024
197
198# True when the address above is one apiplant invented rather than one somebody
199# gave it.
200#
201# A sign-in through a provider that releases no address — X, today — still has
202# to put *something* in the identity column, which is required and unique. It
203# writes `<provider>_<id>@oauth.invalid`, at a TLD RFC 2606 reserves so that it
204# can never resolve.
205#
206# This flag is here, in every app, because the framework is the one inventing
207# that value: fabricating an address and not recording that it was fabricated
208# leaves an app to find out by watching mail bounce. With it, "we have an
209# address for this person" stops being the same question as "there is a string
210# in the email column" — which is the question a welcome email, a newsletter
211# and a CSV export all actually mean to ask.
212[fields.email_placeholder]
213type = "boolean"
214default = false
215
216[fields.email_placeholder.admin]
217visible = false
218"#;
219
220pub const INVITATION_TOML: &str = r#"
233[resource]
234name = "invitation"
235scope = "organization"
236timestamps = true
237
238[permissions]
239list = "role:admin" # admins see who is still pending
240read = "role:admin"
241create = "private" # issued by POST /auth/invitations, never by hand:
242 # a row written directly has no token to send
243update = "private"
244delete = "role:admin" # revoking is deleting the row
245
246[fields.email]
247type = "string"
248required = true
249max_length = 320
250
251[fields.role]
252type = "string" # the role they will hold once they accept
253
254[fields.token_hash]
255type = "string"
256required = true
257unique = true
258hidden = true
259
260[fields.invited_by]
261type = "reference"
262references = "user"
263on_delete = "set_null"
264
265[fields.expires_at]
266type = "timestamp"
267required = true
268
269# Set when the invitation is used. A row with this filled in is history, kept
270# so "who let them in, and when" survives the membership being edited later.
271[fields.accepted_at]
272type = "timestamp"
273"#;
274
275pub const AUTH_TOKEN_TOML: &str = r#"
285[resource]
286name = "auth_token"
287scope = "global"
288timestamps = true
289
290[admin]
291label = "Auth token"
292plural = "Auth tokens"
293visible = false
294
295[permissions]
296list = "private"
297read = "private"
298create = "private"
299update = "private"
300delete = "private"
301
302[fields.user_id]
303type = "reference"
304references = "user"
305required = true
306on_delete = "cascade" # a deleted account takes its live links with it
307
308[fields.kind]
309type = "string"
310required = true # "email_verification" or "password_reset"
311
312[fields.token_hash]
313type = "string"
314required = true
315unique = true
316hidden = true
317
318[fields.expires_at]
319type = "timestamp"
320required = true
321
322# Set the moment the token is spent, so a link in a mailbox works exactly once.
323[fields.used_at]
324type = "timestamp"
325"#;
326
327pub const API_KEY_TOML: &str = r#"
329[resource]
330name = "api_key"
331scope = "global"
332timestamps = true
333
334# "Api key" is what titleising the name produces, and it is not how anybody
335# writes it.
336[admin]
337label = "API key"
338plural = "API keys"
339
340[permissions]
341list = "owner"
342read = "owner"
343create = "authenticated"
344update = "private"
345delete = "owner"
346
347[fields.name]
348type = "string"
349
350[fields.token_hash]
351type = "string"
352required = true
353unique = true
354hidden = true
355
356[fields.owner_id]
357type = "reference"
358references = "user"
359required = true
360"#;
361
362pub const OAUTH_TOML: &str = r#"
380[resource]
381name = "oauth_connection"
382scope = "global"
383timestamps = true
384
385# Readable by whoever it belongs to — `GET <base>/oauth_connection` is "my
386# linked accounts", with no filter in the request saying so — and written by
387# nobody but the framework.
388#
389# `delete` is **private**, which is the one that looks wrong and is not:
390# removing a connection has an invariant that a row deletion cannot see. An
391# account with no password and no second provider becomes permanently
392# unreachable the moment its last connection goes, so unlinking is
393# `DELETE <base>/auth/oauth/{provider}`, which checks what else is left and
394# refuses the last one. Leaving `delete = "owner"` here would put a door beside
395# that check with nothing behind it but the same table.
396[permissions]
397list = "owner"
398read = "owner"
399create = "private"
400update = "private"
401delete = "private"
402
403[fields.provider]
404type = "string"
405required = true
406
407# The provider's own immutable id for this person — GitHub's numeric id,
408# Google's `sub`, X's user id. Never a username: GitHub and X both let people
409# change theirs and let the freed name be taken by somebody else, so an account
410# keyed on one would hand the new owner the old owner's account.
411[fields.provider_user_id]
412type = "string"
413required = true
414
415# `provider:provider_user_id`, so the pair can carry a UNIQUE constraint — a
416# single column is what `unique` can express. It is what makes two simultaneous
417# first-time sign-ins from one GitHub account produce one user instead of two:
418# the loser's insert conflicts, and it reads back the winner's row rather than
419# creating a second account.
420[fields.provider_key]
421type = "string"
422unique = true
423max_length = 320
424
425[fields.owner_id]
426type = "reference"
427references = "user"
428required = true
429on_delete = "cascade"
430
431# What the provider last said about them. `email_verified` is the field the
432# account-matching rule hangs on, so it records what the *provider* claimed
433# rather than what this app would like to be true.
434[fields.email]
435type = "string"
436max_length = 320
437
438[fields.email_verified]
439type = "boolean"
440default = false
441
442[fields.display_name]
443type = "string"
444
445# A plain string, not a `file`: this row is a record of what the provider
446# claimed, so nothing should offer to replace it with an upload.
447[fields.avatar_url]
448type = "string"
449max_length = 1024
450
451[fields.last_login_at]
452type = "timestamp"
453"#;
454
455pub const OAUTH_STATE_TOML: &str = r#"
467[resource]
468name = "oauth_state"
469scope = "global"
470timestamps = true
471
472# Machinery, like `auth_token`: rows appear for ninety seconds while somebody
473# reads a consent screen and are never worth looking at afterwards, so the
474# dashboard does not offer a screen for them.
475[admin]
476label = "OAuth sign-in"
477plural = "OAuth sign-ins"
478visible = false
479
480# Nothing may reach this table over the API — not even the person whose sign-in
481# it is. Every column on it is either a secret or a decision that stops being
482# safe the moment a client can change it, and its only reader is the callback
483# endpoint, which goes to the table directly.
484[permissions]
485list = "private"
486read = "private"
487create = "private"
488update = "private"
489delete = "private"
490
491[fields.provider]
492type = "string"
493required = true
494
495# SHA-256 of the `state` parameter, not the parameter. The value itself travels
496# in a URL, through browser history and the provider's logs; only its hash is
497# kept, so this table leaking does not let anybody finish a flow in progress.
498# SHA-256 rather than argon2 for the same reason an API key's hash is: the
499# callback looks the row up *by* this column.
500[fields.state_hash]
501type = "string"
502required = true
503unique = true
504hidden = true
505
506# The PKCE verifier, where the provider supports PKCE. Only its SHA-256 travels
507# with the authorize redirect, so intercepting that redirect — or the code that
508# comes back on it — is not enough to redeem anything.
509[fields.verifier]
510type = "string"
511hidden = true
512
513# Repeated verbatim in the token request, because the provider compares the two
514# and refuses on any difference. Recorded rather than recomputed so that a
515# config change mid-flow cannot strand a sign-in.
516[fields.redirect_uri]
517type = "string"
518max_length = 1024
519
520# Set when the flow was started by somebody already signed in: this is "connect
521# my GitHub", not "sign me in". It is decided here, while holding that account's
522# session, and never read from the callback — which is the difference between
523# linking an account you can prove is yours and linking any account whose id you
524# can name.
525[fields.link_user_id]
526type = "reference"
527references = "user"
528on_delete = "cascade"
529
530# Where to send the browser afterwards. Only ever a path on this site; see the
531# `return_to` handling in the oauth routes.
532[fields.return_to]
533type = "string"
534max_length = 1024
535
536# How the caller asked to be given the token, overriding `[oauth]
537# token_delivery` for this one flow. Empty means "however the app is
538# configured".
539#
540# It is recorded rather than read from the callback for the ordinary reason:
541# the callback is a request the provider makes, and nothing about it is the
542# app's word. A first-party client — the admin dashboard, say — knows how it
543# wants to receive a token, and this is where it says so.
544[fields.token_delivery]
545type = "string"
546max_length = 16
547
548[fields.expires_at]
549type = "timestamp"
550required = true
551
552# Stamped by the callback. A code may be redeemed once, and so may the state
553# that authorised it: a second callback carrying the same one is a double-click
554# or an attack, and is refused either way.
555[fields.used_at]
556type = "timestamp"
557"#;
558
559pub const BILLING_PRODUCT_TOML: &str = r#"
585[resource]
586name = "billing_product"
587scope = "global"
588timestamps = true
589
590[admin]
591label = "Product"
592plural = "Products"
593
594[permissions]
595list = "public" # a pricing page is read by people with no account
596read = "public"
597create = "role:admin"
598update = "role:admin"
599delete = "role:admin"
600
601# Mirror the catalogue into Stripe. Creating the row creates the product;
602# renaming it renames it; archiving it (active = false) archives it there too.
603[hooks]
604before_create = "apiplant_stripe_product"
605before_update = "apiplant_stripe_product"
606
607[fields.name]
608type = "string"
609required = true
610
611[fields.description]
612type = "text"
613
614# Off the price list without deleting the history that points at it. Stripe
615# calls this "archived", and a product with live subscriptions cannot be
616# deleted in either system — only stopped from being bought again.
617[fields.active]
618type = "boolean"
619default = true
620
621# Free-form facts the app checks when deciding what a plan may do: seat
622# limits, feature flags, an internal tier name. Copied to Stripe as metadata
623# so an operator reading either system sees the same thing.
624[fields.features]
625type = "json"
626
627[fields.stripe_product_id]
628type = "string"
629unique = true
630
631[fields.stripe_product_id.admin]
632readonly = true
633help = "Filled in by Stripe when the product is first saved."
634"#;
635
636pub const BILLING_PRICE_TOML: &str = r#"
645[resource]
646name = "billing_price"
647scope = "global"
648timestamps = true
649
650[admin]
651label = "Price"
652plural = "Prices"
653
654[permissions]
655list = "public"
656read = "public"
657create = "role:admin"
658update = "role:admin" # only `active` and presentation: see the hook
659delete = "role:admin"
660
661[hooks]
662before_create = "apiplant_stripe_price"
663before_update = "apiplant_stripe_price"
664
665[fields.product_id]
666type = "reference"
667references = "billing_product"
668required = true
669on_delete = "cascade" # a deleted product takes its price list with it
670
671[fields.nickname]
672type = "string" # "Monthly", "Yearly (2 months free)"
673
674# The charge, in the currency's smallest unit: 1000 = €10.00 = $10.00.
675[fields.unit_amount]
676type = "big_int"
677required = true
678
679[fields.unit_amount.admin]
680help = "In the smallest unit of the currency — 1000 is 10.00."
681
682[fields.currency]
683type = "string"
684max_length = 3 # ISO 4217; empty takes [payments] currency
685
686# How often it recurs: "month", "year", "week", "day", or empty for a price
687# that is charged once. This is what decides whether buying it starts a
688# subscription or takes a single payment.
689[fields.interval]
690type = "string"
691
692[fields.interval.admin]
693options = ["|One-off", "day|Daily", "week|Weekly", "month|Monthly", "year|Yearly"]
694
695# Charge every N intervals — 3 with interval = "month" is quarterly.
696[fields.interval_count]
697type = "integer"
698default = 1
699
700# Days before the first charge. 0 (the default) starts billing immediately.
701[fields.trial_days]
702type = "integer"
703default = 0
704
705# Whether the amount already includes tax. "exclusive" (the default) means tax
706# is added on top, which is what [payments] automatic_tax computes; "inclusive"
707# means the amount is the total and the tax is worked out from within it.
708[fields.tax_behavior]
709type = "string"
710default = "exclusive"
711
712[fields.tax_behavior.admin]
713options = ["exclusive|Tax added on top", "inclusive|Tax included in the amount"]
714
715[fields.active]
716type = "boolean"
717default = true
718
719# Stripe prices are immutable once created: changing an amount there means
720# creating a new price and archiving the old one, which is exactly what the
721# hook does. The id therefore points at whichever price object is current.
722[fields.stripe_price_id]
723type = "string"
724unique = true
725
726[fields.stripe_price_id.admin]
727readonly = true
728help = "Filled in by Stripe. Changing the amount creates a new price and archives the old one."
729"#;
730
731pub const BILLING_CUSTOMER_TOML: &str = r#"
739[resource]
740name = "billing_customer"
741scope = "organization"
742timestamps = true
743
744[admin]
745label = "Billing customer"
746plural = "Billing customers"
747
748[permissions]
749list = "role:admin" # billing is the admins' business
750read = "role:admin"
751create = "private"
752update = "private"
753delete = "private"
754
755[fields.stripe_customer_id]
756type = "string"
757required = true
758unique = true
759
760# Where invoices and receipts go. Defaults to the address of whoever first
761# started a checkout, and is changed from the Stripe portal.
762[fields.email]
763type = "string"
764max_length = 320
765
766[fields.name]
767type = "string"
768
769# The buyer's VAT/GST number, once they have given one. Kept because it is
770# what turns a taxed sale into a reverse-charge one, and support gets asked
771# about it more than anything else on this table.
772[fields.tax_id]
773type = "string"
774
775# Country Stripe places the customer in, for tax. Two-letter ISO 3166-1.
776[fields.tax_country]
777type = "string"
778max_length = 2
779
780# Everything else Stripe holds about the customer, as last delivered. Kept so
781# an operator can answer a billing question without opening two dashboards.
782[fields.details]
783type = "json"
784
785[fields.details.admin]
786visible = false
787"#;
788
789pub const BILLING_SUBSCRIPTION_TOML: &str = r#"
798[resource]
799name = "billing_subscription"
800scope = "organization"
801timestamps = true
802
803[admin]
804label = "Subscription"
805plural = "Subscriptions"
806
807[permissions]
808list = "member" # members can see what plan they are on
809read = "member"
810create = "private" # started by POST /billing/checkout
811update = "private" # written by the Stripe webhook
812delete = "private"
813
814[fields.price_id]
815type = "reference"
816references = "billing_price"
817on_delete = "set_null" # an archived price outlives its row
818
819[fields.customer_id]
820type = "reference"
821references = "billing_customer"
822on_delete = "cascade"
823
824# Stripe's own words: "trialing", "active", "past_due", "canceled",
825# "incomplete", "incomplete_expired", "unpaid", "paused". Anything checking
826# for a paying customer wants "active" or "trialing" — see the `payments`
827# guide for why "past_due" is a judgement call and not a status.
828[fields.status]
829type = "string"
830required = true
831
832[fields.quantity]
833type = "integer"
834default = 1
835
836[fields.current_period_end]
837type = "timestamp"
838
839# Set when the customer has asked to stop but has already paid through the end
840# of the period: still entitled, not renewing.
841[fields.cancel_at_period_end]
842type = "boolean"
843default = false
844
845[fields.trial_ends_at]
846type = "timestamp"
847
848[fields.canceled_at]
849type = "timestamp"
850
851[fields.stripe_subscription_id]
852type = "string"
853required = true
854unique = true
855
856[fields.stripe_subscription_id.admin]
857readonly = true
858"#;
859
860pub const BILLING_PAYMENT_TOML: &str = r#"
867[resource]
868name = "billing_payment"
869scope = "organization"
870timestamps = true
871
872[admin]
873label = "Payment"
874plural = "Payments"
875
876[permissions]
877list = "role:admin"
878read = "role:admin"
879create = "private"
880update = "private"
881delete = "private"
882
883[fields.customer_id]
884type = "reference"
885references = "billing_customer"
886on_delete = "cascade"
887
888[fields.price_id]
889type = "reference"
890references = "billing_price"
891on_delete = "set_null"
892
893[fields.subscription_id]
894type = "reference"
895references = "billing_subscription"
896on_delete = "set_null" # null for a one-off
897
898# What was actually taken, in the smallest unit, and what of it was tax.
899[fields.amount]
900type = "big_int"
901required = true
902
903[fields.tax_amount]
904type = "big_int"
905default = 0
906
907[fields.currency]
908type = "string"
909max_length = 3
910
911# "succeeded", "pending", "failed", "refunded".
912[fields.status]
913type = "string"
914required = true
915
916[fields.description]
917type = "string"
918
919# The buyer's own receipt, hosted by Stripe. Worth storing: it is the link
920# support is asked for, and it outlives the session that produced it.
921[fields.receipt_url]
922type = "string"
923max_length = 2048
924
925[fields.paid_at]
926type = "timestamp"
927
928[fields.stripe_payment_intent_id]
929type = "string"
930unique = true
931
932[fields.stripe_invoice_id]
933type = "string"
934"#;
935
936pub const BILLING_EVENT_TOML: &str = r#"
947[resource]
948name = "billing_event"
949scope = "global"
950timestamps = true
951
952[admin]
953label = "Billing event"
954plural = "Billing events"
955visible = false
956
957[permissions]
958list = "private"
959read = "private"
960create = "private"
961update = "private"
962delete = "private"
963
964[fields.stripe_event_id]
965type = "string"
966required = true
967unique = true
968
969[fields.kind]
970type = "string"
971required = true # "checkout.session.completed", "invoice.paid", …
972
973# When the work finished. A row with this null is an event that arrived and
974# then failed to process — Stripe will retry it, and this is where to look
975# when it stops retrying.
976[fields.processed_at]
977type = "timestamp"
978
979[fields.error]
980type = "text"
981
982[fields.payload]
983type = "json"
984hidden = true
985"#;
986
987pub fn builtins() -> Vec<(&'static str, &'static str)> {
990 vec![
991 ("organization", ORGANIZATION_TOML),
992 ("user", USER_TOML),
993 ("membership", MEMBERSHIP_TOML),
994 ("membership_role", MEMBERSHIP_ROLE_TOML),
995 ("api_key", API_KEY_TOML),
996 ("oauth_connection", OAUTH_TOML),
997 ("invitation", INVITATION_TOML),
998 ("auth_token", AUTH_TOKEN_TOML),
999 ]
1000}
1001
1002pub fn billing_builtins() -> Vec<(&'static str, &'static str)> {
1016 vec![
1017 ("billing_product", BILLING_PRODUCT_TOML),
1018 ("billing_price", BILLING_PRICE_TOML),
1019 ("billing_customer", BILLING_CUSTOMER_TOML),
1020 ("billing_subscription", BILLING_SUBSCRIPTION_TOML),
1021 ("billing_payment", BILLING_PAYMENT_TOML),
1022 ("billing_event", BILLING_EVENT_TOML),
1023 ]
1024}
1025
1026pub fn oauth_builtins() -> Vec<(&'static str, &'static str)> {
1035 vec![("oauth_state", OAUTH_STATE_TOML)]
1036}
1037
1038pub const QUEUE_MESSAGE_TOML: &str = r#"
1056[resource]
1057name = "queue_message"
1058scope = "global"
1059timestamps = true
1060
1061[admin]
1062label = "Queued message"
1063plural = "Queue"
1064group = "Operations"
1065
1066# Spelled out rather than left to the defaults, because the questions this
1067# screen answers are asked in a hurry: what is stuck, on which topic, for whom,
1068# and how many times has it tried. The generic guess — first string column —
1069# would name a row by `claimed_by`, which is the one column that is usually
1070# empty.
1071display_field = "topic"
1072columns = ["topic", "subscriber", "status", "attempts", "available_at", "processed_at"]
1073
1074# `error` is in here because the way somebody arrives at this table is usually
1075# with half a message from a log line and no idea which row it came from.
1076search_fields = ["topic", "subscriber", "error"]
1077
1078# Visible and readable, because the questions this table answers — what is
1079# stuck, what failed, what is retrying — are asked by a person under time
1080# pressure, and making them write SQL for it is not a kindness. Nothing here is
1081# writable over the API: the subscriber owns these columns.
1082[permissions]
1083list = "role:admin"
1084read = "role:admin"
1085create = "private"
1086update = "private"
1087delete = "private"
1088
1089[fields.topic]
1090type = "string"
1091required = true
1092
1093# The function this row is for. Empty means the message was published to a
1094# topic nothing subscribes to; the row is kept as the record that it happened.
1095[fields.subscriber]
1096type = "string"
1097
1098# pending → running → done, or → failed once the attempts run out. A `pending`
1099# row whose `available_at` is in the future is waiting out a retry backoff; a
1100# `running` row whose `claimed_at` is older than `[queues] lease_secs` belongs
1101# to a subscriber that died, and is taken back on the next sweep.
1102[fields.status]
1103type = "string"
1104required = true
1105default = "pending"
1106
1107[fields.status.admin]
1108options = ["pending|Pending", "running|Running", "done|Done", "failed|Failed"]
1109
1110[fields.payload]
1111type = "json"
1112required = true
1113
1114[fields.attempts]
1115type = "integer"
1116required = true
1117default = 0
1118
1119# The earliest this row may be claimed. Set on publish (now) and pushed forward
1120# by each failure, which is how the retry backoff is expressed without a timer
1121# living in any one process.
1122[fields.available_at]
1123type = "timestamp"
1124required = true
1125
1126# When the current attempt started, and which process took it. Together with
1127# `[queues] lease_secs` these are what let a message survive the subscriber
1128# that was holding it: a `running` row nobody has finished within the lease is
1129# assumed abandoned and offered again.
1130[fields.claimed_at]
1131type = "timestamp"
1132
1133[fields.claimed_by]
1134type = "string"
1135
1136[fields.processed_at]
1137type = "timestamp"
1138
1139# Why the last attempt failed. Kept on a row that later succeeds, because "it
1140# worked on the third try" is worth knowing.
1141[fields.error]
1142type = "text"
1143
1144# Who published it: a principal id, or empty when the publisher was the server
1145# itself (a resource `[publish]` declaration, or a function with no caller).
1146[fields.published_by]
1147type = "string"
1148"#;
1149
1150pub fn queue_builtins() -> Vec<(&'static str, &'static str)> {
1157 vec![("queue_message", QUEUE_MESSAGE_TOML)]
1158}
1159
1160pub fn parse_builtin(toml_src: &str) -> Resource {
1163 let r: Resource = toml::from_str(toml_src).expect("built-in resource TOML is valid");
1164 r.validate().expect("built-in resource is valid");
1165 r
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171
1172 #[test]
1173 fn all_builtins_parse() {
1174 for (name, src) in builtins()
1175 .into_iter()
1176 .chain(billing_builtins())
1177 .chain(oauth_builtins())
1178 .chain(queue_builtins())
1179 {
1180 let r = parse_builtin(src);
1181 assert_eq!(r.meta.name, name);
1182 }
1183 }
1184
1185 #[test]
1189 fn every_billing_resource_is_namespaced() {
1190 for (name, _) in billing_builtins() {
1191 assert!(name.starts_with("billing_"), "`{name}` is unprefixed");
1192 }
1193 }
1194
1195 #[test]
1199 fn billing_references_resolve_within_the_app() {
1200 let known: Vec<&str> = builtins()
1201 .into_iter()
1202 .chain(billing_builtins())
1203 .map(|(name, _)| name)
1204 .collect();
1205 for (name, src) in billing_builtins() {
1206 let resource = parse_builtin(src);
1207 for (field, spec) in &resource.fields {
1208 if let Some(target) = &spec.references {
1209 assert!(
1210 known.contains(&target.as_str()),
1211 "{name}.{field} points at an unknown `{target}`"
1212 );
1213 }
1214 }
1215 }
1216 }
1217}