mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

0001_checkin.sql

Language SQL6.2 KB120 lines

apps/worker/migrations/0001_checkin.sql

  1. -- The end-of-day note. Three questions, one row per person per day.
  2. --
  3. -- D1 rather than KV, which the rest of the board's state uses (rule 14).
  4. --
  5. -- Everything else the board owns is one JSON blob per concern, read whole and
  6. -- written whole: inbox:filters, inbox:read, board:notes, inbox:signatures,
  7. -- inbox:replied. That works because each of them is small and none of them is a
  8. -- series. This is a series: a row a day, for as long as somebody is new, read
  9. -- back as "the last three weeks, including the days she did not write one".
  10. -- Doing that in a single KV value means read-modify-write on the whole history
  11. -- for every submission, and KV is eventually consistent by up to about a
  12. -- minute, so two writes a minute apart can lose one. Losing a note is losing the only copy of something a person
  13. -- typed once.
  14. --
  15. -- So: a table, an upsert, and a query with a date range. That is what SQL is.
  16. --
  17. -- Apply it (the database does not exist until somebody creates it):
  18. -- cd apps/worker
  19. -- npx wrangler d1 create meridian-checkins # prints database_id
  20. -- # paste that id into wrangler.jsonc, then:
  21. -- npx wrangler d1 migrations apply meridian-checkins --local # a dry run
  22. -- npx wrangler d1 migrations apply meridian-checkins --remote # production
  23. --
  24. -- Until the binding exists the board says the note store is not configured, in
  25. -- those words, on the Onboarding tab. It does not say "no notes"; see the
  26. -- prime invariant in docs/ARCHITECTURE.md. An unconfigured store and an empty
  27. -- one are not the same fact and must not render the same way.
  28. -- Strict, so a TEXT column cannot quietly hold an integer and a typo in a
  29. -- binding cannot write a shape nothing reads back. D1's SQLite supports it.
  30. CREATE TABLE IF NOT EXISTS checkin (
  31. id INTEGER PRIMARY KEY,
  32. -- The roster display name, and the column everything is filed under.
  33. --
  34. -- Not the email, because a seat can change hands. Most addresses here are
  35. -- role mailboxes: when accounts@ changes hands, the next holder signs in as
  36. -- the same string. Keyed by
  37. -- address, that person would inherit the previous holder's history: weeks
  38. -- of somebody else's "what I'm unsure about" under their name. Keyed by roster
  39. -- name, a seat change starts an empty history and leaves the old one intact
  40. -- and correctly attributed. config/checkin.ts is where an address is mapped to
  41. -- a name, by hand.
  42. person TEXT NOT NULL,
  43. -- What actually authenticated, recorded beside it rather than instead of it.
  44. -- If these two ever disagree with config/checkin.ts, this column is the
  45. -- evidence of which mailbox was signed in as.
  46. author_email TEXT NOT NULL,
  47. -- YYYY-MM-DD in `day_tz`. The day the note is about, which is not always the
  48. -- day it was typed: a note written at 00:30 files against the day that just
  49. -- ended (CHECKIN_GRACE_UNTIL_HOUR). The GLOB is here because a malformed day
  50. -- would sort into the wrong place in every range query and never throw.
  51. day TEXT NOT NULL CHECK (day GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'),
  52. -- The zone that `day` was computed in, so a row stays legible if the board's
  53. -- clock ever moves. Everything is America/Chicago (MERIDIAN_TZ).
  54. day_tz TEXT NOT NULL,
  55. -- The three answers, in the order they are asked. Column names match
  56. -- CHECKIN_QUESTIONS[].id and the form field names; changing one is a
  57. -- migration, not an edit.
  58. did TEXT NOT NULL,
  59. unsure TEXT NOT NULL,
  60. need TEXT NOT NULL,
  61. submitted_at TEXT NOT NULL, -- ISO 8601 UTC, the first submission
  62. updated_at TEXT NOT NULL, -- ISO 8601 UTC, the version stored here
  63. revisions INTEGER NOT NULL DEFAULT 1 CHECK (revisions >= 1),
  64. -- A row must say something. All three blank is not an answer; it is
  65. -- indistinguishable from not having written one, and a day that reads blank
  66. -- when she never opened the form is the prime invariant broken in the place
  67. -- it matters most. The Worker refuses an all-blank submission too; this is
  68. -- the same rule at the storage layer, where nothing can route around it.
  69. -- One or two blanks are fine and render as "left blank", explicitly.
  70. --
  71. -- It sits down here because it is a table constraint and SQLite rejects the
  72. -- whole CREATE if one appears between two columns. The test that applies
  73. -- this file to a real engine is what catches that.
  74. CHECK (length(trim(did)) + length(trim(unsure)) + length(trim(need)) > 0),
  75. -- One note per person per day. The upsert depends on this constraint; without
  76. -- it a second submission appends a second row and the log shows one day
  77. -- twice, with no way to tell which is current.
  78. UNIQUE (person, day)
  79. ) STRICT;
  80. -- The readers' log is "this person, newest day first, back N days".
  81. CREATE INDEX IF NOT EXISTS checkin_person_day ON checkin (person, day DESC);
  82. -- Every version ever submitted, including the first. Append-only.
  83. --
  84. -- Edits are kept because the note can be rewritten, and the person reading it
  85. -- is usually reading it the same evening. Without this table an edit silently
  86. -- replaces text a reader has already read, and neither side can tell that
  87. -- happened. With it, the log can say "edited, 2 versions" and show the earlier
  88. -- one. It is also the only thing here that cannot be undone from the page,
  89. -- which is exactly the property inbox:sent:* has and for the same reason.
  90. --
  91. -- The author is told this on the form, in plain words. A kept history nobody
  92. -- mentions is a different feature from a kept history everybody knows about.
  93. CREATE TABLE IF NOT EXISTS checkin_revision (
  94. -- Autoincrement, unlike the table above: an append-only log wants ids that are
  95. -- never reused, so "the row after id 40" means the same thing forever.
  96. id INTEGER PRIMARY KEY AUTOINCREMENT,
  97. person TEXT NOT NULL,
  98. day TEXT NOT NULL CHECK (day GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'),
  99. revision INTEGER NOT NULL CHECK (revision >= 1),
  100. did TEXT NOT NULL,
  101. unsure TEXT NOT NULL,
  102. need TEXT NOT NULL,
  103. author_email TEXT NOT NULL,
  104. written_at TEXT NOT NULL, -- ISO 8601 UTC
  105. UNIQUE (person, day, revision)
  106. ) STRICT;
  107. CREATE INDEX IF NOT EXISTS checkin_revision_person_day
  108. ON checkin_revision (person, day DESC, revision DESC);