Chambers AppDeveloper documentation

Data model

Every table, generated from the migrations.

Generated by applying all 47 migrations in packages/core/migrations to an in-memory SQLite, the same SQL D1 runs, and reading the result: 87 tables and views (2 views), 156 triggers. Each entry shows the columns with their keys and checks, the rules over whole rows (composite keys, unique indexes, table checks) and the triggers. A table's purpose and its columns' notes are the comments written above them in the migrations; 37 tables have no such comment, so no purpose line is shown for them.

The data model at a glanceTables grouped by domain, with the number of tables in each and a few of the central ones. Accounts belong to people; the structure (chambers, units, admin grants) scopes membership, events, the visibility of profiles, and news. The operation record, the audit log, notices and delivery run across every domain.Accounts · 6 tablesuserssessionslogin_codesconsentsStructure · 8 tableschambersunitsadmin_grantschamber_brandsMembership · 16 tablesapplicationsmembershipscommittee_membershipspositionspoints_entriesEvents · 14 tableseventsevent_wavesevent_registrationsevent_check_insProfiles and matching · 20 tablesprofilesmediamatching_optinsintroductionsboard_postsNews · 12 tablesnews_sourcesnews_itemsnews_analysisdigest_snapshotsAcross every domain · 11 tablesaudit_entry_units · audit_log · batch_guard · data_request_transitions · data_requestsnotice_preferences · notifications · operations · outbox · push_subscriptions · …
The data model at a glance. Tables grouped by domain, with the number of tables in each and a few of the central ones. Accounts belong to people; the structure (chambers, units, admin grants) scopes membership, events, the visibility of profiles, and news. The operation record, the audit log, notices and delivery run across every domain.

How the schema holds its rules

Nearly every table is STRICT, ids are text, and times are ISO 8601 text in UTC. Every chamber's rows carry its chamber id, and composite foreign keys such as (chamber_id, committee_id) → units (chamber_id, id) make it impossible for a row to point into another chamber.

Because a command reads, decides in code and then writes in one batch, the database itself enforces what must never happen, whatever raced in between. Triggers refuse impossible states: a membership moved to another branch, a position held by someone who is not an active member, an event moved to another unit, a public wave on an event that does not reach the whole chamber. History tables are append-only: triggers refuse any update or delete on application_transitions, audit_entry_units, audit_log, data_request_transitions, membership_transitions, points_entries, tier_transitions. A command maps the message a trigger raises to a refusal code.

The *_guard tables hold no data. A command adds an insert into one to its batch only when what it read has since changed (a photo replaced meanwhile, a member who now holds a higher admin right), and the insert fails on a named CHECK constraint, or on a trigger added later for a further case, named after the refusal the command's onConflict maps it to. So a stale batch fails as a whole and is answered with that refusal.

Guard tables and what fails their inserts
Guard tableNamed constraintsTriggers
batch_guardholds_admin_role import_gonebatch_guard_board_post_changed
chamber_logo_guardbrand_logo_changed
event_photo_guardevent_photo_changed event_photo_not_allowed
event_staff_guardevent_staff_removed
higher_admin_guardhigher_admin
profile_photo_guardphoto_changed

Domains

87 in all

Accounts and sessions

platform_operators

Created in 0001_foundation.sql. 2 columns.

Columns of platform_operators
ColumnTypeKeys and checksNote
user_idTEXT · requiredprimary key
→ users.id
granted_atTEXT · required

users

Created in 0001_foundation.sql. 3 columns.

Columns of users
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
emailTEXT · requiredunique
check email = lower(trim(email)) AND length(email) BETWEEN 3 AND 254
created_atTEXT · required

consents

Created in 0002_sign_in.sql. 5 columns, 1 rule.

Columns of consents
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
user_idTEXT · required→ users.id
notice_versionTEXT · requiredcheck length(notice_version) BETWEEN 1 AND 32
localeTEXT · required
accepted_atTEXT · required
  • unique (user_id, notice_version)

login_code_uses

A code can be used once: the primary key makes a second use fail.

Created in 0002_sign_in.sql. 2 columns.

Columns of login_code_uses
ColumnTypeKeys and checksNote
code_idTEXT · requiredprimary key
→ login_codes.id
used_atTEXT · required

login_codes

Created in 0002_sign_in.sql. 6 columns.

Columns of login_codes
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
emailTEXT · required
code_hashTEXT · required
attemptsINTEGER · requiredcheck attempts >= 0Default 0.
created_atTEXT · required
expires_atTEXT · required

sessions

Created in 0002_sign_in.sql. 7 columns.

Columns of sessions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
user_idTEXT · required→ users.id
token_hashTEXT · requiredunique
created_atTEXT · required
rotated_atTEXT · required
expires_atTEXT · required
revoked_atTEXT · may be null

Chambers and structure

chambers

Created in 0001_foundation.sql. 5 columns.

Columns of chambers
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
slugTEXT · requiredunique
check slug = lower(slug) AND length(slug) BETWEEN 2 AND 64
nameTEXT · requiredcheck length(trim(name)) BETWEEN 1 AND 160
created_byTEXT · required→ users.id
created_atTEXT · required

admin_grants

An admin right: a named user holds a role at one unit, reaching that unit and everything beneath it.

Created in 0003_structure.sql. 11 columns, 1 trigger, 3 rules.

Columns of admin_grants
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
unit_idTEXT · required
user_idTEXT · required→ users.id
roleTEXT · requiredcheck role IN ('chamber_admin', 'branch_admin', 'exco_admin')
granted_byTEXT · required→ users.id
granted_atTEXT · required
grant_reasonTEXT · requiredcheck length(trim(grant_reason)) BETWEEN 1 AND 500
revoked_byTEXT · may be null→ users.id
revoked_atTEXT · may be null
revoke_reasonTEXT · may be null
  • (chamber_id, unit_id) → units (chamber_id, id)
  • unique index admin_grants_one_active: (unit_id, user_id) where revoked_at IS NULL
  • check: (revoked_at IS NULL) = (revoked_by IS NULL) AND (revoked_at IS NULL) = (revoke_reason IS NULL)
TriggerOnRefuses with
admin_grants_role_fits_unitbefore insertthe admin role does not fit the unit

units

Created in 0003_structure.sql. 6 columns, 2 triggers, 3 rules.

Columns of units
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
kindTEXT · requiredcheck kind IN ('chamber', 'branch', 'committee')
parent_idTEXT · may be null
nameTEXT · requiredcheck length(trim(name)) BETWEEN 1 AND 160
created_atTEXT · required
  • (chamber_id, parent_id) → units (chamber_id, id)
  • unique index units_sibling_name: (chamber_id, parent_id, name) where parent_id IS NOT NULL
  • unique (chamber_id, id)
TriggerOnRefuses with
units_fixed_shapebefore update of id, chamber_id, kind, parent_ida unit cannot be moved or change kind
units_shapebefore inserta chamber unit is the root of its own chamber

chamber_hosts

Which chamber an address belongs to: a custom domain or a subdomain. Only the platform operator maps them. A host is a lower-case name without a port; each host belongs to one chamber at most.

Created in 0017_chamber_brands.sql. 4 columns.

Columns of chamber_hosts
ColumnTypeKeys and checksNote
hostTEXT · requiredprimary key
check host = lower(host) AND length(host) BETWEEN 1 AND 253 AND host NOT LIKE '%:%' AND host NOT LIKE '% %'
chamber_idTEXT · required→ chambers.id
created_atTEXT · required
created_byTEXT · required→ users.id

Applications and membership

application_transitions

Append-only history. The unique version makes two simultaneous decisions on one application collide.

Created in 0004_membership.sql; changed in 0048_operations_and_log_reach.sql. 9 columns, 2 triggers, 1 rule.

Columns of application_transitions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
application_idTEXT · required→ applications.id
versionINTEGER · requiredcheck version >= 0
from_statusTEXT · may be null
to_statusTEXT · required
actor_user_idTEXT · required→ users.id
reasonTEXT · may be null
occurred_atTEXT · required
operation_idTEXT · may be nullEvery transition names the operation that made it, as the history of an application, a membership, a committee place, a tier or a data request is meant to. The executor's operation id is written in the same batch as the transition; rows made before this migration, and by scheduled work, have none.
  • unique (application_id, version)
TriggerOnRefuses with
application_transitions_no_deletebefore deleteapplication_transitions is append-only
application_transitions_no_updatebefore updateapplication_transitions is append-only

applications

A person asks a committee to admit them. While it is submitted they are a Friend of that chamber.

Created in 0004_membership.sql. 18 columns, 3 triggers, 4 rules.

Columns of applications
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
committee_idTEXT · required
user_idTEXT · required→ users.id
branch_idTEXT · may be nullThe branch the applicant chose. Required only when a first membership starts in a national committee.
full_nameTEXT · requiredcheck length(trim(full_name)) BETWEEN 1 AND 160
phoneTEXT · requiredcheck length(trim(phone)) BETWEEN 6 AND 32
companyTEXT · requiredcheck length(trim(company)) BETWEEN 1 AND 160
positionTEXT · requiredcheck length(trim(position)) BETWEEN 1 AND 120
industryTEXT · requiredcheck length(trim(industry)) BETWEEN 1 AND 120
referrer_emailTEXT · may be null
localeTEXT · requiredcheck locale IN ('zh', 'en')The language the applicant used, so messages about the decision reach them in it.
statusTEXT · requiredcheck status IN ('submitted', 'approved', 'declined', 'withdrawn')
submitted_atTEXT · required
decided_atTEXT · may be null
decided_byTEXT · may be null→ users.id
decision_noteTEXT · may be nullcheck decision_note IS NULL OR length(decision_note) <= 500
versionINTEGER · requiredcheck version >= 0Default 0.
  • (chamber_id, branch_id) → units (chamber_id, id)
  • (chamber_id, committee_id) → units (chamber_id, id)
  • unique index applications_one_open: (committee_id, user_id) where status = 'submitted'
  • check: (status = 'submitted') = (decided_at IS NULL)
TriggerOnRefuses with
applications_committee_is_committeebefore insertan application is made to a committee
member_search_application_insertafter insertkeeps derived rows in step
member_search_application_updateafter updatekeeps derived rows in step

committee_memberships

A member's place in one committee.

Created in 0004_membership.sql; changed in 0009_membership_lifecycle.sql, 0048_operations_and_log_reach.sql. 14 columns, 5 triggers, 4 rules.

Columns of committee_memberships
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
membership_idTEXT · required
committee_idTEXT · required
user_idTEXT · required→ users.id
statusTEXT · requiredcheck status IN ('active', 'ended')
application_idTEXT · may be null→ applications.id
joined_atTEXT · required
ended_atTEXT · may be null
ended_byTEXT · may be null→ users.id
end_reasonTEXT · may be null
ended_howTEXT · may be nullcheck ended_how IS NULL OR ended_how IN ('left', 'removed', 'membership_ended')How a place in a committee ended: the member left, an admin removed them, or their membership ended.
joined_operation_idTEXT · may be nullA committee place keeps its current row only: the operation that opened it, and the one that ended it.
ended_operation_idTEXT · may be null
  • (chamber_id, committee_id) → units (chamber_id, id)
  • (chamber_id, membership_id) → memberships (chamber_id, id)
  • unique index committee_memberships_one_active: (committee_id, user_id) where status = 'active'
  • check: (status = 'active') = (ended_at IS NULL)
TriggerOnRefuses with
committee_memberships_active_memberbefore insertmembership_not_active: a committee place is only for an active membership
committee_memberships_ended_finalbefore updatecommittee_membership_ended: this committee membership has already ended
committee_memberships_keep_oneafter update of statuslast_committee: a member must keep at least one committee
committee_memberships_no_deletebefore deletecommittee memberships are never deleted
committee_memberships_only_endbefore updatea committee membership can only be ended, saying who ended it, when and how

membership_transitions

Created in 0004_membership.sql; changed in 0048_operations_and_log_reach.sql. 9 columns, 2 triggers, 1 rule.

Columns of membership_transitions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
membership_idTEXT · required→ memberships.id
versionINTEGER · requiredcheck version >= 0
from_statusTEXT · may be null
to_statusTEXT · required
actor_user_idTEXT · required→ users.id
reasonTEXT · may be null
occurred_atTEXT · required
operation_idTEXT · may be null
  • unique (membership_id, version)
TriggerOnRefuses with
membership_transitions_no_deletebefore deletemembership_transitions is append-only
membership_transitions_no_updatebefore updatemembership_transitions is append-only

memberships

A person's membership of one chamber, in exactly one branch.

Created in 0004_membership.sql; changed in 0008_tiers_points.sql. 10 columns, 10 triggers, 4 rules.

Columns of memberships
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
branch_idTEXT · required
statusTEXT · requiredcheck status IN ('active', 'suspended', 'resigned', 'terminated')
joined_atTEXT · required
ended_atTEXT · may be null
versionINTEGER · requiredcheck version >= 0Default 0.
tier_idTEXT · may be null→ tiers.idA member's current tier, and the version every points or tier change bumps.
standing_versionINTEGER · requiredDefault 0.
  • (chamber_id, branch_id) → units (chamber_id, id)
  • unique index memberships_one_open: (chamber_id, user_id) where status IN ('active', 'suspended')
  • unique (chamber_id, id)
  • check: (status IN ('active', 'suspended')) = (ended_at IS NULL)
TriggerOnRefuses with
matching_membership_endedafter update of statuskeeps derived rows in step
memberships_allowed_movesbefore update of statusmembership_move: that change of membership is not allowed
memberships_branch_is_branchbefore inserta membership belongs to a branch
memberships_finalbefore updatemembership_final: an ended membership cannot change
memberships_fixed_branchbefore update of chamber_id, user_id, branch_ida membership cannot change chamber, person or branch
memberships_no_deletebefore deletememberships are never deleted
memberships_standing_versionbefore update of standing_versionstanding changed: member
memberships_start_tierafter insertkeeps derived rows in step
memberships_tier_in_chamberbefore update of tier_ida member
memberships_tier_not_retiredbefore update of tier_idstanding changed: tier retired

Notices and delivery

notifications

What a person is told, kept as a kind and parameters so it reads in whatever language they use.

Created in 0004_membership.sql. 7 columns.

Columns of notifications
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
user_idTEXT · required→ users.id
chamber_idTEXT · may be null→ chambers.id
kindTEXT · requiredcheck length(kind) BETWEEN 1 AND 64
params_jsonTEXT · requiredDefault '{}'.
created_atTEXT · required
read_atTEXT · may be null

outbox

Messages to deliver outside the app, written in the same batch as the change that caused them.

Created in 0004_membership.sql; changed in 0006_outbox_sending.sql, 0031_push.sql, 0037_deletion_reaches_everything.sql. 9 columns.

Columns of outbox
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
channelTEXT · requiredcheck channel IN ('email', 'push')
payload_jsonTEXT · required
statusTEXT · requiredcheck status IN ('pending', 'sending', 'sent', 'failed')
attemptsINTEGER · requiredcheck attempts >= 0Default 0.
created_atTEXT · required
sent_atTEXT · may be null
last_errorTEXT · may be null
chamber_idTEXT · may be nullEmail queued by a chamber is dropped when that chamber deletes the person's data, so each outbox row records the chamber it was sent for (null for platform email). Email queued before this column existed is marked '*': its chamber is unknown, so any chamber's deletion of the person's data reaches it.

notice_preferences

A person's choice for one kind of notice on one channel. No row means on. Email about a decision on the person cannot be turned off. For opt-in topics (eventOpened) no row means off; see the notice topics on the Architecture page.

Created in 0031_push.sql; changed in 0041_event_notices.sql. 5 columns, 1 rule.

Columns of notice_preferences
ColumnTypeKeys and checksNote
user_idTEXT · requiredprimary key
→ users.id
topicTEXT · requiredprimary key
check length(topic) BETWEEN 1 AND 64
channelTEXT · requiredprimary key
check channel IN ('push', 'email', 'inbox')
enabledINTEGER · requiredcheck enabled IN (0, 1)
updated_atTEXT · required
  • check: NOT (channel = 'email' AND enabled = 0 AND topic IN ('applicationDecided', 'membershipChanged', 'dataRequestDecided'))

push_subscriptions

One row per browser on one device. The endpoint is the push service's address for it; the keys encrypt messages for it. The session it was added in is kept so signing out there removes it.

Created in 0031_push.sql. 10 columns.

Columns of push_subscriptions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
user_idTEXT · required→ users.id
session_idTEXT · may be null
endpointTEXT · requiredunique
check substr(endpoint, 1, 8) = 'https://' AND length(endpoint) <= 2048
p256dhTEXT · requiredcheck length(p256dh) = 87
authTEXT · requiredcheck length(auth) = 22
localeTEXT · requiredcheck locale IN ('zh', 'en')The language the person last used the app in on this device; notices are written in it.
labelTEXT · requiredcheck length(label) BETWEEN 1 AND 80A short description of the device, such as "Safari on iPhone", never the full user agent.
created_atTEXT · required
updated_atTEXT · required

Positions

positions

Created in 0007_positions.sql; changed in 0018_positions_end_with_place.sql. 14 columns, 4 triggers, 5 rules.

Columns of positions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
membership_idTEXT · required
committee_idTEXT · required
user_idTEXT · required→ users.id
titleTEXT · requiredcheck length(trim(title)) BETWEEN 1 AND 80 AND title = trim(title)Free text in whatever language the chamber uses, such as 主席 or Chairman.
starts_onTEXT · requiredcheck coalesce(date(starts_on) = starts_on, 0)Calendar dates (YYYY-MM-DD) in the chamber's time zone. The end date is the first day the position no longer counts.
ends_onTEXT · may be nullcheck ends_on IS NULL OR coalesce(date(ends_on) = ends_on, 0)
recorded_byTEXT · required→ users.id
recorded_atTEXT · required
ended_byTEXT · may be null→ users.id
ended_atTEXT · may be null
end_reasonTEXT · may be nullcheck end_reason IS NULL OR length(trim(end_reason)) BETWEEN 1 AND 500
ended_howTEXT · may be nullcheck ended_how IS NULL OR ended_how IN ('left_committee', 'removed_from_committee', 'membership_ended')
  • (chamber_id, committee_id) → units (chamber_id, id)
  • (chamber_id, membership_id) → memberships (chamber_id, id)
  • check: ends_on IS NULL OR ends_on >= starts_on
  • check: (ended_at IS NULL) = (ended_by IS NULL) AND (ended_at IS NULL) = (end_reason IS NULL)
  • check: ended_at IS NULL OR ends_on IS NOT NULL
TriggerOnRefuses with
positions_end_oncebefore update of ends_on, ended_by, ended_at, end_reasonposition already ended
positions_fitbefore inserta position is held in a committee
positions_fixedbefore update of id, chamber_id, membership_id, committee_id, user_id, title, starts_on, recorded_by, recorded_ata position cannot be changed, only ended
positions_no_deletebefore deletepositions are never deleted

higher_admin_guard

A guard for changing another admin's membership. D1 has no interactive transactions, so a suspension, ending or committee removal repeats its rank check inside its batch: it inserts here only when the member now holds an admin right at or above the actor's level, and every insert fails with the constraint named higher_admin, which the executor turns into that refusal. The table always stays empty.

Created in 0018_positions_end_with_place.sql. 1 column, 1 rule.

Columns of higher_admin_guard
ColumnTypeKeys and checksNote
refusalTEXT · required
  • check higher_admin: refusal IS NULL

Tiers and points

points_entries

The append-only ledger. A member's total is the sum of their entries.

Created in 0008_tiers_points.sql. 11 columns, 4 triggers, 6 rules.

Columns of points_entries
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
membership_idTEXT · required
sourceTEXT · requiredcheck source IN ('attendance', 'organising', 'referral', 'contribution', 'correction', 'manual')
amountINTEGER · requiredcheck amount <> 0 AND amount BETWEEN -1000000 AND 1000000
reasonTEXT · may be nullcheck reason IS NULL OR length(trim(reason)) BETWEEN 1 AND 500
corrects_idTEXT · may be null→ points_entries.id
cause_kindTEXT · may be nullWhat caused an automatic award, such as an event attended or the application of a person referred.
cause_idTEXT · may be null
actor_user_idTEXT · may be null→ users.id
occurred_atTEXT · required
  • (chamber_id, membership_id) → memberships (chamber_id, id)
  • unique index points_entries_once_per_cause: (membership_id, source, cause_kind, cause_id) where cause_id IS NOT NULL
  • check: (source = 'correction') = (corrects_id IS NOT NULL)
  • check: source = 'correction' OR amount > 0
  • check: source NOT IN ('correction', 'contribution', 'manual') OR reason IS NOT NULL
  • check: (cause_kind IS NULL) = (cause_id IS NULL)
TriggerOnRefuses with
points_entries_correction_same_memberbefore inserta correction refers to an entry of the same member
points_entries_no_deletebefore deletepoints_entries is append-only
points_entries_no_updatebefore updatepoints_entries is append-only
points_entries_referral_oncebefore insertstanding changed: referral already paid

points_settings

The points each source is worth in a chamber, and the versions that guard standing changes.

Created in 0008_tiers_points.sql. 10 columns, 3 triggers.

Columns of points_settings
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
attendanceINTEGER · requiredcheck attendance BETWEEN 0 AND 100000
organisingINTEGER · requiredcheck organising BETWEEN 0 AND 100000
referralINTEGER · requiredcheck referral BETWEEN 0 AND 100000
ladder_versionINTEGER · requiredDefault 0.
ladder_checkINTEGER · requiredDefault 0.
points_versionINTEGER · requiredDefault 0.
points_checkINTEGER · requiredDefault 0.
updated_atTEXT · required
updated_byTEXT · may be null→ users.id
TriggerOnRefuses with
points_settings_ladder_checkbefore update of ladder_checkstanding changed: ladder
points_settings_ladder_versionbefore update of ladder_versionstanding changed: ladder
points_settings_points_checkbefore update of points_checkstanding changed: points

tier_transitions

Every change of a member's tier: automatic raises and changes by hand, which carry a reason.

Created in 0008_tiers_points.sql; changed in 0048_operations_and_log_reach.sql. 10 columns, 2 triggers, 2 rules.

Columns of tier_transitions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
membership_idTEXT · required
from_tier_idTEXT · may be null→ tiers.id
to_tier_idTEXT · required→ tiers.id
kindTEXT · requiredcheck kind IN ('automatic', 'manual')
actor_user_idTEXT · may be null→ users.id
reasonTEXT · may be nullcheck reason IS NULL OR length(trim(reason)) BETWEEN 1 AND 500
occurred_atTEXT · required
operation_idTEXT · may be null
  • (chamber_id, membership_id) → memberships (chamber_id, id)
  • check: kind <> 'manual' OR (reason IS NOT NULL AND actor_user_id IS NOT NULL)
TriggerOnRefuses with
tier_transitions_no_deletebefore deletetier_transitions is append-only
tier_transitions_no_updatebefore updatetier_transitions is append-only

tiers

A chamber's ladder. Tiers are never deleted; one taken off the ladder is retired, so history keeps its name.

Created in 0008_tiers_points.sql. 7 columns, 3 triggers, 1 rule.

Columns of tiers
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
name_zhTEXT · requiredcheck length(trim(name_zh)) BETWEEN 1 AND 60
name_enTEXT · requiredcheck length(trim(name_en)) BETWEEN 1 AND 60
thresholdINTEGER · requiredcheck threshold >= 0
created_atTEXT · required
retired_atTEXT · may be null
  • unique (chamber_id, id)
TriggerOnRefuses with
tiers_fixed_chamberbefore update of chamber_ida tier cannot move to another chamber
tiers_no_deletebefore deletetiers are retired, not deleted
tiers_retire_unheldbefore update of retired_atstanding changed: tier held

Visibility

visibility_rules

Created in 0011_visibility.sql. 5 columns, 3 triggers, 2 rules.

Columns of visibility_rules
ColumnTypeKeys and checksNote
chamber_idTEXT · required→ chambers.id
itemTEXT · required
audienceTEXT · requiredcheck audience IN ('public', 'friend', 'office_holder', 'tier')
tier_idTEXT · may be null→ tiers.id
allowedINTEGER · requiredcheck allowed IN (0, 1)
  • unique index visibility_rules_one: (chamber_id, item, audience, coalesce(tier_id, ''))
  • check: (audience = 'tier') = (tier_id IS NOT NULL)
TriggerOnRefuses with
visibility_rules_known_itembefore insertunknown gateable item
visibility_rules_no_updatebefore updatevisibility rules are replaced, not updated
visibility_rules_tier_in_chamberbefore inserta visibility tier belongs to its chamber

visibility_settings

Created in 0011_visibility.sql. 4 columns, 1 trigger.

Columns of visibility_settings
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
versionINTEGER · requiredcheck version >= 1
updated_atTEXT · required
updated_byTEXT · required→ users.id
TriggerOnRefuses with
visibility_settings_versionbefore update of versionvisibility changed

Data requests and erasure

data_request_transitions

Created in 0012_data_requests.sql; changed in 0048_operations_and_log_reach.sql. 9 columns, 2 triggers, 1 rule.

Columns of data_request_transitions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
request_idTEXT · required→ data_requests.id
versionINTEGER · requiredcheck version >= 0
from_statusTEXT · may be null
to_statusTEXT · requiredcheck to_status IN ('received', 'completed', 'refused')
actor_user_idTEXT · required→ users.id
noteTEXT · may be null
occurred_atTEXT · required
operation_idTEXT · may be null
  • unique (request_id, version)
TriggerOnRefuses with
data_request_transitions_no_deletebefore deletedata_request_transitions is append-only
data_request_transitions_no_updatebefore updatedata_request_transitions is append-only

data_requests

Created in 0012_data_requests.sql. 11 columns, 4 triggers, 6 rules.

Columns of data_requests
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
user_idTEXT · required→ users.id
kindTEXT · requiredcheck kind IN ('chamber', 'account')
chamber_idTEXT · may be null→ chambers.idThe chamber asked to delete what it holds. Null for an account erasure.
statusTEXT · requiredcheck status IN ('received', 'completed', 'refused')
received_atTEXT · required
due_atTEXT · required
decided_atTEXT · may be null
decided_byTEXT · may be null→ users.id
outcome_noteTEXT · may be nullcheck outcome_note IS NULL OR length(trim(outcome_note)) BETWEEN 1 AND 500What the person deciding wrote: why it was refused, or anything about how it was completed.
versionINTEGER · requiredcheck version >= 0Default 0.
  • unique index data_requests_one_open: (user_id, kind, coalesce(chamber_id, '')) where status = 'received'
  • check: (kind = 'chamber') = (chamber_id IS NOT NULL)
  • check: due_at > received_at
  • check: (status = 'received') = (decided_at IS NULL)
  • check: (decided_at IS NULL) = (decided_by IS NULL)
  • check: status <> 'refused' OR outcome_note IS NOT NULL
TriggerOnRefuses with
data_requests_finalbefore updatedata_request_decided: this request has already been decided
data_requests_fixedbefore update of id, user_id, kind, chamber_id, received_at, due_ata data request cannot change who asked, about what or when
data_requests_movebefore update of statusdata_request_decided: a decision takes the next version
data_requests_no_deletebefore deletedata requests are never deleted

batch_guard

Guards a batch repeats from what its command read, since D1 has no interactive transactions: a command inserts here only when what it checked no longer holds, and each insert fails with the constraint named after the refusal, which the executor turns into that refusal. The table always stays empty.

Created in 0037_deletion_reaches_everything.sql. 1 column, 1 trigger, 2 rules.

Columns of batch_guard
ColumnTypeKeys and checksNote
refusalTEXT · required
  • check holds_admin_role: refusal <> 'holds_admin_role'
  • check import_gone: refusal <> 'import_gone'
TriggerOnRefuses with
batch_guard_board_post_changedbefore insertboard_post_changed

News and the digest

news_fetch_attempts

One row per fetch of a source, kept for 30 days, so the operator can see what failed and when.

Created in 0013_news_sources.sql. 9 columns, 1 rule.

Columns of news_fetch_attempts
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
source_idTEXT · required→ news_sources.id
attempted_atTEXT · required
outcomeTEXT · requiredcheck outcome IN ('fetched', 'not_modified', 'failed')
http_statusINTEGER · may be null
items_foundINTEGER · requiredDefault 0.
items_newINTEGER · requiredDefault 0.
errorTEXT · may be nullcheck error IN ('unreachable', 'timeout', 'http_error', 'too_large', 'not_a_feed')
error_detailTEXT · may be null
  • check: (outcome = 'failed') = (error IS NOT NULL)

news_items

A headline. url_key is the canonical address without scheme or "www.", so the same story reached by slightly different links is one row; title_key is the normalised title used to spot the same story carried by another source.

Created in 0013_news_sources.sql. 11 columns, 1 rule.

Columns of news_items
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
source_idTEXT · required→ news_sources.id
url_keyTEXT · requiredunique
linkTEXT · requiredcheck link LIKE 'https://%' OR link LIKE 'http://%'
titleTEXT · requiredcheck length(title) BETWEEN 1 AND 500
title_keyTEXT · required
languageTEXT · requiredcheck language IN ('zh', 'en', 'ms')
published_atTEXT · may be null
descriptionTEXT · may be nullcheck description IS NULL OR length(description) <= 600The feed's own short description; only for a source whose terms are 'summary_allowed'.
termsTEXT · requiredcheck terms IN ('headline_link', 'summary_allowed')
fetched_atTEXT · required
  • check: description IS NULL OR terms = 'summary_allowed'

news_sources

The operator's allowed list.

Created in 0013_news_sources.sql. 23 columns, 6 triggers.

Columns of news_sources
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
nameTEXT · requiredcheck length(trim(name)) BETWEEN 1 AND 120
feed_urlTEXT · requiredunique
check feed_url LIKE 'https://%' OR feed_url LIKE 'http://%'
kindTEXT · requiredcheck kind IN ('rss', 'atom', 'json_api')
languageTEXT · requiredcheck language IN ('zh', 'en', 'ms')
termsTEXT · requiredcheck terms IN ('headline_link', 'summary_allowed')
permission_noteTEXT · requiredcheck length(trim(permission_note)) BETWEEN 1 AND 2000What permits us to use the source, with the evidence (a terms page, a letter, a date).
activeINTEGER · requiredcheck active IN (0, 1)
min_interval_minutesINTEGER · requiredcheck min_interval_minutes BETWEEN 15 AND 1440
versionINTEGER · requiredDefault 0.
created_atTEXT · required
created_byTEXT · may be null→ users.id
updated_atTEXT · required
updated_byTEXT · may be null→ users.id
etagTEXT · may be nullFetch state, written by the fetch job only.
last_modifiedTEXT · may be null
next_due_atTEXT · may be null
last_attempt_atTEXT · may be null
last_success_atTEXT · may be null
last_errorTEXT · may be nullcheck last_error IN ('unreachable', 'timeout', 'http_error', 'too_large', 'not_a_feed')Why the last fetch failed: a code the interface explains, the server's status, and the technical detail.
last_error_statusINTEGER · may be null
last_error_detailTEXT · may be null
consecutive_failuresINTEGER · requiredDefault 0.
TriggerOnRefuses with
news_sources_descriptions_withdrawnafter update of termskeeps derived rows in step
news_sources_digest_withdrawnafter update of terms, activekeeps derived rows in step
news_sources_no_deletebefore deletenews sources are paused, not deleted
news_sources_terms_restoredafter update of termskeeps derived rows in step
news_sources_terms_withdrawnafter update of termskeeps derived rows in step
news_sources_versionbefore update of versionsource changed

news_analysis

What the platform worked out about each news item: its topics and relevance score, and the machine-written text shown with it (a two-sentence summary in Chinese and English for a source whose terms allow it; otherwise only the headline machine-translated into the interface languages). Every item gets a row in the same batch that stores it, classified by the keyword rules; the scheduled analysis then asks the model and replaces the classification when the answer is valid. A summary is never kept for a headline-and-link source. The triggers refuse one unless both the item (as fetched) and its source (now) allow summaries, and a source changed to headline and link loses the summaries of its items at once. Concurrency (D1 has no interactive transactions): a run claims an item by bumping `attempts` with a guarded UPDATE, and writes its answer only while `attempts` still holds the value it set, so two runs at once cannot both ask the model about one item or overwrite each other.

Created in 0016_news_analysis.sql. 13 columns, 2 triggers.

Columns of news_analysis
ColumnTypeKeys and checksNote
item_idTEXT · requiredprimary key
→ news_items.id
topicsTEXT · requiredcheck json_valid(topics) AND json_type(topics) = 'array' AND json_array_length(topics) BETWEEN 1 AND 3The topic names, as a JSON array of one to three entries from the fixed list in core (news/topics.ts).
scoreINTEGER · requiredcheck score BETWEEN 0 AND 100
classified_byTEXT · requiredcheck classified_by IN ('keywords', 'model')
summary_enTEXT · may be nullcheck summary_en IS NULL OR length(summary_en) <= 600Machine-written; each is labelled as such wherever it is shown.
summary_zhTEXT · may be nullcheck summary_zh IS NULL OR length(summary_zh) <= 300
title_enTEXT · may be nullcheck title_en IS NULL OR length(title_en) <= 500
title_zhTEXT · may be nullcheck title_zh IS NULL OR length(title_zh) <= 500
text_statusTEXT · requiredcheck text_status IN ('pending', 'done', 'failed')pending: the model has not answered usefully yet; done: the text is here; failed: given up after repeated unusable answers.
modelTEXT · may be null
attemptsINTEGER · requiredDefault 0.
attempted_atTEXT · may be null
updated_atTEXT · required
TriggerOnRefuses with
news_analysis_summary_insertbefore insertsummary not allowed for this source
news_analysis_summary_updatebefore update of summary_en, summary_zhsummary not allowed for this source

digest_added_items

An item an admin wrote: their own headline and summary, in either or both languages, and an optional link; or a chamber announcement. Withdrawn, not deleted, so the record of who added it stays.

Created in 0030_news_digest.sql. 13 columns, 1 trigger, 3 rules.

Columns of digest_added_items
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
unit_idTEXT · required
digest_dateTEXT · requiredcheck digest_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
kindTEXT · requiredcheck kind IN ('added', 'announcement')
headlineTEXT · requiredcheck length(trim(headline)) BETWEEN 1 AND 200
summary_zhTEXT · may be nullcheck summary_zh IS NULL OR length(trim(summary_zh)) BETWEEN 1 AND 600
summary_enTEXT · may be nullcheck summary_en IS NULL OR length(trim(summary_en)) BETWEEN 1 AND 1200
linkTEXT · may be nullcheck link IS NULL OR link LIKE 'https://%' OR link LIKE 'http://%'
added_byTEXT · required→ users.id
added_atTEXT · required
withdrawn_byTEXT · may be null→ users.id
withdrawn_atTEXT · may be null
  • (chamber_id, unit_id) → units (chamber_id, id)
  • check: summary_zh IS NOT NULL OR summary_en IS NOT NULL
  • check: (withdrawn_at IS NULL) = (withdrawn_by IS NULL)
TriggerOnRefuses with
digest_added_items_withdrawn_oncebefore update of withdrawn_atdigest item already withdrawn

digest_days

One row per chamber and day that has been written to; see the note on concurrency above.

Created in 0030_news_digest.sql. 3 columns, 1 trigger.

Columns of digest_days
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
digest_dateTEXT · requiredprimary key
versionINTEGER · requiredcheck version >= 1
TriggerOnRefuses with
digest_days_versionbefore update of versiondigest changed

digest_edits

An admin's pin or hide of a fetched item in one unit's digest for one day.

Created in 0030_news_digest.sql. 8 columns, 2 rules.

Columns of digest_edits
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
unit_idTEXT · required
digest_dateTEXT · requiredcheck digest_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
item_idTEXT · required→ news_items.id
actionTEXT · requiredcheck action IN ('pin', 'hide')
decided_byTEXT · required→ users.id
decided_atTEXT · required
  • (chamber_id, unit_id) → units (chamber_id, id)
  • unique index digest_edits_one_per_item: (unit_id, digest_date, item_id)

digest_settings

Created in 0030_news_digest.sql. 8 columns, 1 trigger.

Columns of digest_settings
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
topicsTEXT · requiredcheck json_valid(topics) AND json_type(topics) = 'array' AND json_array_length(topics) >= 1Topic names from the fixed list in core (news/topics.ts), as a JSON array of at least one.
source_idsTEXT · requiredcheck json_valid(source_ids) AND json_type(source_ids) = 'array'Ids of news sources from the operator's allowed list, as a JSON array; empty means only the admins' own items.
ready_minuteINTEGER · requiredcheck ready_minute BETWEEN 0 AND 1439Minutes after midnight, Malaysia time, when the digest is ready.
varies_byTEXT · requiredcheck varies_by IN ('chamber', 'branch', 'committee')
versionINTEGER · required
updated_atTEXT · required
updated_byTEXT · required→ users.id
TriggerOnRefuses with
digest_settings_versionbefore update of versiondigest settings changed

digest_snapshots

What one unit's digest held for one day, as assembled at the ready time and updated by later edits. items_json is the ordered list of items; settings_json the topics and sources it was assembled with; ranked_json the stories ranked at assembly (the first ones beyond what is shown), so a later edit re-composes from the same ranking instead of from scores that have changed since.

Created in 0030_news_digest.sql. 10 columns, 2 triggers, 2 rules.

Columns of digest_snapshots
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required
unit_idTEXT · required
digest_dateTEXT · requiredcheck digest_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
ready_atTEXT · required
assembled_atTEXT · required
settings_jsonTEXT · requiredcheck json_valid(settings_json)
items_jsonTEXT · requiredcheck json_valid(items_json) AND json_type(items_json) = 'array'
ranked_jsonTEXT · requiredcheck json_valid(ranked_json) AND json_type(ranked_json) = 'array'
updated_atTEXT · required
  • (chamber_id, unit_id) → units (chamber_id, id)
  • unique index digest_snapshots_one_per_day: (unit_id, digest_date)
TriggerOnRefuses with
digest_snapshots_no_summary_insertbefore insertsummary not allowed for this source
digest_snapshots_no_summary_updatebefore update of items_json, ranked_jsonsummary not allowed for this source

digest_email_preferences

Created in 0036_digest_email.sql. 5 columns.

Columns of digest_email_preferences
ColumnTypeKeys and checksNote
user_idTEXT · requiredprimary key
→ users.id
chamber_idTEXT · requiredprimary key
→ chambers.id
frequencyTEXT · requiredcheck frequency IN ('daily', 'weekly')
localeTEXT · requiredcheck locale IN ('zh', 'en')The language the email is written in: the interface language the member chose it in.
updated_atTEXT · required

digest_email_sends

One row per member, chamber and period handled: 'daily:2026-11-13' or 'weekly:2026-11-16' (the Monday). The primary key is what keeps an email to one per period: two scheduled runs at once cannot both write it, and the outbox row is written in the same batch, so the loser queues nothing. 'skipped' records a period with nothing to send the member (the digest was hidden from them, or empty), so the next run does not look at them again. Kept three weeks.

Created in 0036_digest_email.sql. 5 columns.

Columns of digest_email_sends
ColumnTypeKeys and checksNote
user_idTEXT · requiredprimary key
→ users.id
chamber_idTEXT · requiredprimary key
→ chambers.id
periodTEXT · requiredprimary key
check period GLOB 'daily:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' OR period GLOB 'weekly:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
outcomeTEXT · requiredcheck outcome IN ('sent', 'skipped')
atTEXT · required

news_translation_recheck

News items to check once more under the translation rules of ticket #61: each item's language is read from its own text (a source labelled English may publish Malay headlines), and a stored headline translation that changed a number, carries kana or stray Latin words, or is missing for a language the item now needs is cleared and asked for again. The rules are code (news/language.ts, news/translation-check.ts), not SQL, so this migration only queues every stored item; the news analysis run drains the queue a bounded number at a time before it asks the model, and removes each item as it is checked, so each is checked once. Holds no personal data.

Created in 0038_news_translation_recheck.sql. 1 column.

Columns of news_translation_recheck
ColumnTypeKeys and checksNote
item_idTEXT · requiredprimary key
→ news_items.id

Member import

imported_member_details

What an imported member's row said about them, which member.list and member.detail show until they have an approved application of their own. Kept after the preview is gone.

Created in 0014_member_import.sql. 12 columns, 4 triggers, 1 rule.

Columns of imported_member_details
ColumnTypeKeys and checksNote
membership_idTEXT · requiredprimary key
chamber_idTEXT · required
user_idTEXT · required→ users.id
import_idTEXT · required
full_nameTEXT · may be nullcheck full_name IS NULL OR length(trim(full_name)) BETWEEN 1 AND 160
phoneTEXT · may be nullcheck phone IS NULL OR length(trim(phone)) BETWEEN 6 AND 32
companyTEXT · may be nullcheck company IS NULL OR length(trim(company)) BETWEEN 1 AND 160
positionTEXT · may be nullcheck position IS NULL OR length(trim(position)) BETWEEN 1 AND 120
industryTEXT · may be nullcheck industry IS NULL OR length(trim(industry)) BETWEEN 1 AND 120
localeTEXT · requiredcheck locale IN ('zh', 'en')The language the row asked for, else the chamber's default, so later messages reach them in it.
imported_byTEXT · required→ users.id
imported_atTEXT · required
  • (chamber_id, membership_id) → memberships (chamber_id, id)
TriggerOnRefuses with
imported_member_details_erase_onlybefore updateimported details are kept as they were imported, until erased
imported_member_details_no_deletebefore deleteimported details are kept as they were imported
member_search_import_insertafter insertkeeps derived rows in step
member_search_import_updateafter updatekeeps derived rows in step

member_import_chunks

Up to a hundred rows of the file, the check of each, and once imported, the outcome of each.

Created in 0014_member_import.sql. 7 columns, 1 trigger, 1 rule.

Columns of member_import_chunks
ColumnTypeKeys and checksNote
import_idTEXT · requiredprimary key
→ member_imports.id
chunkINTEGER · requiredprimary key
check chunk >= 0
rows_jsonTEXT · required
checks_jsonTEXT · required
statusTEXT · requiredcheck status IN ('pending', 'done')
results_jsonTEXT · may be null
done_atTEXT · may be null
  • check: (status = 'done') = (done_at IS NOT NULL) AND (status = 'done') = (results_json IS NOT NULL)
TriggerOnRefuses with
member_import_chunks_oncebefore updateimport chunk already imported

member_imports

Created in 0014_member_import.sql. 11 columns, 2 triggers, 2 rules.

Columns of member_imports
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
branch_idTEXT · requiredThe branch new memberships are fixed to, and the committee rows without one join.
committee_idTEXT · required
created_byTEXT · required→ users.id
created_atTEXT · required
expires_atTEXT · requiredThirty minutes after the last thing done with it. An expired preview cannot be used.
headers_jsonTEXT · required
mapping_jsonTEXT · required
row_countINTEGER · requiredcheck row_count BETWEEN 1 AND 2000
versionINTEGER · requiredDefault 0.
  • (chamber_id, committee_id) → units (chamber_id, id)
  • (chamber_id, branch_id) → units (chamber_id, id)
TriggerOnRefuses with
member_imports_fixedbefore update of id, chamber_id, branch_id, committee_id, created_by, created_at, headers_json, row_countan import cannot change what it imports into
member_imports_versionbefore update of versionimport changed

Audit and operations

audit_log

Append-only: every executed or refused command.

Created in 0001_foundation.sql; changed in 0005_audit_scope.sql. 10 columns, 2 triggers, 1 rule.

Columns of audit_log
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
occurred_atTEXT · required
capabilityTEXT · required
actor_user_idTEXT · may be null
chamber_idTEXT · may be null
outcomeTEXT · requiredcheck outcome IN ('executed', 'refused')
refusal_codeTEXT · may be null
operation_idTEXT · may be null
detail_jsonTEXT · requiredDefault '{}'.
unit_idTEXT · may be null
  • check: (outcome = 'refused') = (refusal_code IS NOT NULL)
TriggerOnRefuses with
audit_log_no_deletebefore deleteaudit_log is append-only
audit_log_no_updatebefore updateaudit_log is append-only

operations

One row per completed command. Repeating an operation id returns the stored result.

Created in 0001_foundation.sql. 6 columns.

Columns of operations
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
check length(id) BETWEEN 8 AND 128
capabilityTEXT · required
actor_user_idTEXT · may be null
input_hashTEXT · required
result_jsonTEXT · required
created_atTEXT · required

scheduled_runs

Each run of scheduled work (outbox retries, the daily backup), so the operator's health page can show when it last ran and how it went.

Created in 0015_scheduled_runs.sql. 8 columns, 2 rules.

Columns of scheduled_runs
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
taskTEXT · required
periodTEXT · may be nullFor work done once per period, such as the daily backup: the period it covers ('2026-11-13').
started_atTEXT · required
finished_atTEXT · may be null
outcomeTEXT · requiredcheck outcome IN ('running', 'succeeded', 'failed')
counts_jsonTEXT · requiredDefault '{}'.
errorTEXT · may be nullScrubbed of personal data before it is stored.
  • unique index scheduled_runs_one_per_period: (task, period) where period IS NOT NULL AND outcome IN ('running', 'succeeded')
  • check: (outcome = 'running') = (finished_at IS NULL)

audit_entry_units

The further units a log entry concerns, beyond the unit it was taken at, so each of their admins sees it in their log: the committees and branch of a member whose points, tier or membership changed. Append-only, like the log itself.

Created in 0048_operations_and_log_reach.sql. 2 columns, 2 triggers.

Columns of audit_entry_units
ColumnTypeKeys and checksNote
audit_idTEXT · requiredprimary key
→ audit_log.id
unit_idTEXT · requiredprimary key
→ units.id
TriggerOnRefuses with
audit_entry_units_no_deletebefore deleteaudit_entry_units is append-only
audit_entry_units_no_updatebefore updateaudit_entry_units is append-only

Brands

chamber_brands

Created in 0017_chamber_brands.sql; changed in 0044_chamber_logos.sql. 8 columns, 1 trigger.

Columns of chamber_brands
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
name_zhTEXT · requiredcheck length(trim(name_zh)) BETWEEN 1 AND 80
name_enTEXT · requiredcheck length(trim(name_en)) BETWEEN 1 AND 80
markTEXT · requiredcheck length(mark) = 1One character, pressed into the chamber's mark.
colourTEXT · requiredcheck length(colour) = 7 AND colour GLOB '#[0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F]'#RRGGBB, upper case. Its contrast against white text is checked in code (WCAG AA).
versionINTEGER · requiredcheck version >= 1
updated_atTEXT · required
updated_byTEXT · required→ users.id
TriggerOnRefuses with
chamber_brands_versionbefore update of versionbrand changed

chamber_logo_guard

Every insert fails with the constraint named brand_logo_changed, which the command turns into that refusal; it inserts only when what it checked no longer holds. The table always stays empty.

Created in 0044_chamber_logos.sql. 1 column, 1 rule.

Columns of chamber_logo_guard
ColumnTypeKeys and checksNote
refusalTEXT · required
  • check brand_logo_changed: refusal IS NULL

chamber_logo_objects

One row per stored object. The height is in pixels; the width follows the logo's shape.

Created in 0044_chamber_logos.sql. 5 columns, 1 rule.

Columns of chamber_logo_objects
ColumnTypeKeys and checksNote
keyTEXT · requiredprimary key
check key LIKE 'media/%'
media_idTEXT · required
heightINTEGER · requiredcheck height > 0
content_typeTEXT · requiredcheck content_type IN ('image/webp')
byte_sizeINTEGER · requiredcheck byte_size > 0
  • unique (media_id, height)

chamber_logos

The logo: at most one per chamber, under an opaque media id. Each stored height is its own object, media/<mediaId>/<height>.webp, listed in chamber_logo_objects. A logo is chamber data, shown to everyone on the chamber's addresses, so it holds no personal data beyond who saved it. An upload stores its objects before its batch runs, so the batch must fail (and the upload remove them) whenever the logo moved on meanwhile: a replacement updates only the row with the media id and version it read, a first logo collides on the primary key, and a guard insert then fails unless the logo is now the upload's (chamber_logo_guard). Replacing or removing a logo queues its object keys in media_deletions in the same batch. Backups copy these rows but never the R2 objects (docs/operations/restore.md).

Created in 0044_chamber_logos.sql. 5 columns, 1 trigger.

Columns of chamber_logos
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
media_idTEXT · requiredunique
versionINTEGER · requiredcheck version >= 1
updated_atTEXT · required
updated_byTEXT · required→ users.id
TriggerOnRefuses with
chamber_logos_versionbefore updatebrand_logo_changed: a logo takes the next version

Events, registration and check-in

event_wave_patterns

A chamber's default release pattern: the organising Exco, each tier from highest to lowest, Friends, then (if included) the public, each opening gap_hours after the last. A chamber with no row uses the defaults in code.

Created in 0028_events.sql; changed in 0049_wave_pattern_audiences.sql. 9 columns, 3 triggers.

Columns of event_wave_patterns
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
gap_hoursINTEGER · requiredcheck gap_hours BETWEEN 1 AND 720
include_publicINTEGER · requiredcheck include_public IN (0, 1)
versionINTEGER · requiredcheck version >= 1
updated_atTEXT · required
updated_byTEXT · required→ users.id
include_excoINTEGER · requiredcheck include_exco IN (0, 1)Default 1.
include_tiersINTEGER · requiredcheck include_tiers IN (0, 1)Default 1.
include_friendsINTEGER · requiredcheck include_friends IN (0, 1)Default 1.
TriggerOnRefuses with
event_wave_patterns_members_insertbefore insertevent_pattern_no_members
event_wave_patterns_members_updatebefore update of include_tiers, include_friendsevent_pattern_no_members
event_wave_patterns_versionbefore update of versionpattern changed

event_waves

One step of an event's release, in order. 'exco' is the organising committee's office-holders.

Created in 0028_events.sql. 5 columns, 3 triggers, 2 rules.

Columns of event_waves
ColumnTypeKeys and checksNote
event_idTEXT · requiredprimary key
→ events.id
positionINTEGER · requiredprimary key
check position BETWEEN 0 AND 49
audienceTEXT · requiredcheck audience IN ('exco', 'tier', 'friend', 'public')
tier_idTEXT · may be null→ tiers.id
opens_atTEXT · required
  • unique index event_waves_one_audience: (event_id, audience, coalesce(tier_id, ''))
  • check: (audience = 'tier') = (tier_id IS NOT NULL)
TriggerOnRefuses with
event_waves_completed_deletebefore deleteevent completed
event_waves_completed_insertbefore insertevent completed
event_waves_fitbefore inserta wave

events

Created in 0028_events.sql. 25 columns, 9 triggers, 9 rules.

Columns of events
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
unit_idTEXT · required
reachTEXT · requiredcheck reach IN ('committee', 'branch', 'chamber')
reach_unit_idTEXT · required
title_zhTEXT · requiredcheck length(title_zh) <= 160Default ''.
title_enTEXT · requiredcheck length(title_en) <= 160Default ''.
description_zhTEXT · requiredcheck length(description_zh) <= 5000Default ''.
description_enTEXT · requiredcheck length(description_en) <= 5000Default ''.
venueTEXT · requiredcheck length(trim(venue)) BETWEEN 1 AND 300
starts_atTEXT · requiredInstants in UTC (ISO 8601); shown in the chamber's time zone.
ends_atTEXT · required
capacityINTEGER · requiredcheck capacity BETWEEN 1 AND 100000
guest_allowanceINTEGER · requiredcheck guest_allowance BETWEEN 0 AND 10
statusTEXT · requiredcheck status IN ('draft', 'published', 'cancelled')
created_byTEXT · required→ users.id
created_atTEXT · required
updated_byTEXT · required→ users.id
updated_atTEXT · required
published_byTEXT · may be null→ users.id
published_atTEXT · may be null
cancelled_byTEXT · may be null→ users.id
cancelled_atTEXT · may be null
cancel_reasonTEXT · may be nullcheck cancel_reason IS NULL OR length(trim(cancel_reason)) BETWEEN 1 AND 500
versionINTEGER · requiredcheck version >= 0Default 0.
  • (chamber_id, reach_unit_id) → units (chamber_id, id)
  • (chamber_id, unit_id) → units (chamber_id, id)
  • unique (chamber_id, id)
  • check: length(trim(title_zh)) > 0 OR length(trim(title_en)) > 0
  • check: ends_at > starts_at
  • check: (published_at IS NULL) = (published_by IS NULL)
  • check: status = 'draft' OR published_at IS NOT NULL OR status = 'cancelled'
  • check: (status = 'cancelled') = (cancelled_at IS NOT NULL)
  • check: (cancelled_at IS NULL) = (cancelled_by IS NULL) AND (cancelled_at IS NULL) = (cancel_reason IS NULL)
TriggerOnRefuses with
events_cancelled_finalbefore updateevent cancelled
events_capacity_holdsbefore update of capacitycapacity below places taken
events_completed_detailsbefore update of starts_at, ends_at, venue, capacity, guest_allowance, reach, reach_unit_id, unit_idevent completed
events_completed_finalbefore update of statusevent completed
events_fixedbefore update of id, chamber_id, unit_id, created_by, created_atan event cannot move to another unit
events_not_deletedbefore deleteevents are cancelled, not deleted
events_reach_fits_insertbefore insertreach above the owner
events_reach_fits_updatebefore update of reach, reach_unit_idreach above the owner
events_versionbefore update of versionevent changed

event_registrations

Created in 0032_event_registrations.sql; changed in 0039_event_check_ins.sql. 15 columns, 9 triggers, 5 rules.

Columns of event_registrations
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
event_idTEXT · required
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
statusTEXT · requiredcheck status IN ('registered', 'waitlisted', 'offered', 'cancelled', 'declined', 'expired', 'released')
guests_jsonTEXT · requiredcheck json_valid(guests_json) AND json_type(guests_json) = 'array'The guests' names, as a JSON array of strings. Default '[]'.
placesINTEGER · requiredcheck places BETWEEN 1 AND 11
localeTEXT · requiredcheck locale IN ('zh', 'en')The language the person registered in, for what they are told about it.
created_atTEXT · requiredWhen they registered or joined the waitlist: the waitlist's order after tier rank.
updated_atTEXT · required
offered_atTEXT · may be null
offer_expires_atTEXT · may be null
ended_atTEXT · may be null
versionINTEGER · requiredcheck version >= 0Default 0.
walk_inINTEGER · requiredcheck walk_in IN (0, 1)Default 0.
  • (chamber_id, event_id) → events (chamber_id, id)
  • unique index event_registrations_one_live: (event_id, user_id) where status IN ('registered', 'waitlisted', 'offered')
  • check: places = 1 + json_array_length(guests_json)
  • check: status <> 'offered' OR (offered_at IS NOT NULL AND offer_expires_at IS NOT NULL)
  • check: (ended_at IS NULL) = (status IN ('registered', 'waitlisted', 'offered'))
TriggerOnRefuses with
event_registrations_capacity_insertbefore insertevent full
event_registrations_capacity_updatebefore update of statusevent full
event_registrations_fixedbefore update of id, event_id, chamber_id, user_id, guests_json, places, locale, created_ata registration
event_registrations_movesbefore update of statusregistration changed
event_registrations_offer_in_timebefore update of statusoffer expired
event_registrations_openbefore insertevent not open for registration
event_registrations_versionbefore update of versionregistration changed
event_registrations_walk_in_fixedbefore update of walk_ina registration
event_registrations_walk_in_shapebefore inserta walk-in is registered for one place

event_check_ins

Created in 0039_event_check_ins.sql. 13 columns, 5 triggers, 4 rules.

Columns of event_check_ins
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
keyTEXT · requiredunique
check length(key) BETWEEN 16 AND 64
Generated on the device that made the check-in; the same key is the same check-in.
event_idTEXT · required
chamber_idTEXT · required→ chambers.id
registration_idTEXT · required→ event_registrations.id
user_idTEXT · required→ users.id
methodTEXT · requiredcheck method IN ('scan', 'self', 'manual', 'walk_in')
by_user_idTEXT · required→ users.id
checked_in_atTEXT · required
recorded_atTEXT · required
undone_atTEXT · may be null
undone_byTEXT · may be null→ users.id
undo_reasonTEXT · may be nullcheck undo_reason IS NULL OR length(trim(undo_reason)) BETWEEN 1 AND 500
  • (chamber_id, event_id) → events (chamber_id, id)
  • unique index event_check_ins_one_standing: (registration_id) where undone_at IS NULL
  • check: (undone_at IS NULL) = (undone_by IS NULL) AND (undone_at IS NULL) = (undo_reason IS NULL)
  • check: method <> 'self' OR by_user_id = user_id
TriggerOnRefuses with
event_check_ins_fixedbefore update of id, key, event_id, chamber_id, registration_id, user_id, method, by_user_id, checked_in_at, recorded_ata check-in
event_check_ins_not_completedbefore insertevent completed
event_check_ins_registeredbefore insertcheck-in needs a registration
event_check_ins_undo_not_completedbefore update of undone_atevent completed
event_check_ins_undo_oncebefore update of undone_at, undone_by, undo_reasoncheck-in already undone

event_notices_sent

One row per notice sent: a reminder for one registration, or an opening notice for one person and event. The unique indexes are what keep each to once: two scheduled runs at once cannot both write the row, and the notices are written in the same batch from the rows that batch wrote (batch_id), so the loser tells nobody. A reminder records the start it was for (starts_at), so an event moved after its reminder went out is reminded again before its new start. Personal data: in the data export, removed by both erasures.

Created in 0041_event_notices.sql. 8 columns, 5 rules.

Columns of event_notices_sent
ColumnTypeKeys and checksNote
kindTEXT · requiredcheck kind IN ('reminder', 'opened')
event_idTEXT · required
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
registration_idTEXT · may be null
starts_atTEXT · may be null
batch_idTEXT · required
sent_atTEXT · required
  • (chamber_id, event_id) → events (chamber_id, id)
  • unique index event_notices_sent_opened: (event_id, user_id) where kind = 'opened'
  • unique index event_notices_sent_reminder: (registration_id, starts_at) where kind = 'reminder'
  • check: (kind = 'reminder') = (registration_id IS NOT NULL)
  • check: (kind = 'reminder') = (starts_at IS NOT NULL)

event_opening_progress

How far each event's opening notices have got: every wave that opened up to opened_through, as the event stood at event_version, has been worked through. A later wave opening, or any change to the event (its waves included), makes it due again. Holds no personal data.

Created in 0041_event_notices.sql. 4 columns.

Columns of event_opening_progress
ColumnTypeKeys and checksNote
event_idTEXT · requiredprimary key
→ events.id
opened_throughTEXT · required
event_versionINTEGER · required
updated_atTEXT · required

event_completions

Created in 0045_event_staff.sql. 4 columns, 2 triggers, 1 rule.

Columns of event_completions
ColumnTypeKeys and checksNote
event_idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
completed_atTEXT · required
completed_byTEXT · may be null→ users.idNull when the schedule completed it.
  • (chamber_id, event_id) → events (chamber_id, id)
TriggerOnRefuses with
event_completions_after_endbefore insertevent not ended
event_completions_fixedbefore updateevent completed

event_organisers

Created in 0045_event_staff.sql. 12 columns, 3 triggers, 4 rules.

Columns of event_organisers
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
event_idTEXT · required
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
roleTEXT · requiredcheck role IN ('helper', 'speaker', 'volunteer')
noteTEXT · may be nullcheck note IS NULL OR length(trim(note)) BETWEEN 1 AND 300
pointsINTEGER · requiredcheck points >= 0The points awarded: the chamber's organising value at the time.
awarded_byTEXT · required→ users.id
awarded_atTEXT · required
removed_byTEXT · may be null→ users.id
removed_atTEXT · may be null
remove_reasonTEXT · may be nullcheck remove_reason IS NULL OR length(trim(remove_reason)) BETWEEN 1 AND 500
  • (chamber_id, event_id) → events (chamber_id, id)
  • unique index event_organisers_once: (event_id, user_id)
  • check: (removed_at IS NULL) = (removed_by IS NULL) AND (removed_at IS NULL) = (remove_reason IS NULL)
  • check: awarded_by <> user_id
TriggerOnRefuses with
event_organisers_awardedbefore insertorganising needs an event that has started
event_organisers_fixedbefore update of id, event_id, chamber_id, user_id, role, note, points, awarded_by, awarded_atan organising award is fixed
event_organisers_removed_oncebefore update of removed_at, removed_by, remove_reasonorganising award already removed

event_staff

Created in 0045_event_staff.sql. 8 columns, 3 triggers, 4 rules.

Columns of event_staff
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
event_idTEXT · required
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
named_byTEXT · required→ users.id
named_atTEXT · required
removed_byTEXT · may be null→ users.id
removed_atTEXT · may be null
  • (chamber_id, event_id) → events (chamber_id, id)
  • unique index event_staff_once: (event_id, user_id) where removed_at IS NULL
  • check: (removed_at IS NULL) = (removed_by IS NULL)
  • check: named_by <> user_id
TriggerOnRefuses with
event_staff_fixedbefore update of id, event_id, chamber_id, user_id, named_by, named_atevent staff details are fixed
event_staff_namedbefore insertevent staff need an open event
event_staff_removed_oncebefore update of removed_at, removed_byevent staff already removed

event_staff_guard

Guards a batch repeats from what its command read: a member of staff removed after a door command read their appointment. Always empty.

Created in 0045_event_staff.sql. 1 column, 1 rule.

Columns of event_staff_guard
ColumnTypeKeys and checksNote
refusalTEXT · required
  • check event_staff_removed: refusal <> 'event_staff_removed'

Profiles, media and the directory

profile_companies

Industries are the sections of the Malaysia Standard Industrial Classification (MSIC 2008).

Created in 0029_profiles.sql. 9 columns, 3 triggers.

Columns of profile_companies
ColumnTypeKeys and checksNote
user_idTEXT · requiredprimary key
→ profiles.user_id
ordinalINTEGER · requiredprimary key
check ordinal BETWEEN 0 AND 4
nameTEXT · requiredcheck length(trim(name)) BETWEEN 1 AND 120
industryTEXT · requiredcheck industry IN ('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U')
positionTEXT · requiredcheck length(trim(position)) BETWEEN 1 AND 100
websiteTEXT · may be nullcheck website IS NULL OR website LIKE 'https://%'
descriptionTEXT · may be null
sizeTEXT · may be nullcheck size IS NULL OR size IN ('s1_4', 's5_29', 's30_74', 's75_200', 's201_plus')
founded_yearINTEGER · may be nullcheck founded_year IS NULL OR founded_year >= 1900
TriggerOnRefuses with
member_search_company_deleteafter deletekeeps derived rows in step
member_search_company_insertafter insertkeeps derived rows in step
member_search_company_updateafter updatekeeps derived rows in step

profiles

Created in 0029_profiles.sql; changed in 0050_profile_stand_ins.sql. 18 columns, 7 triggers, 2 rules.

Columns of profiles
ColumnTypeKeys and checksNote
user_idTEXT · requiredprimary key
→ users.id
name_zhTEXT · may be nullcheck name_zh IS NULL OR length(trim(name_zh)) BETWEEN 1 AND 100
name_enTEXT · may be nullcheck name_en IS NULL OR length(trim(name_en)) BETWEEN 1 AND 100
offersTEXT · may be null
needsTEXT · may be null
phoneTEXT · may be null
emailTEXT · may be null
whatsappTEXT · may be null
wechatTEXT · may be null
bioTEXT · may be null
social_jsonTEXT · requiredcheck json_valid(social_json) AND json_type(social_json) = 'array'Default '[]'.
languages_jsonTEXT · requiredcheck json_valid(languages_json) AND json_type(languages_json) = 'array'Default '[]'.
visibility_jsonTEXT · requiredcheck json_valid(visibility_json) AND json_type(visibility_json) = 'object'
versionINTEGER · requiredcheck version >= 1
created_atTEXT · required
updated_atTEXT · required
offer_industries_jsonTEXT · requiredcheck json_valid(offer_industries_json) AND json_type(offer_industries_json) = 'array'Default '[]'.
need_industries_jsonTEXT · requiredcheck json_valid(need_industries_json) AND json_type(need_industries_json) = 'array'Default '[]'.
  • check: name_zh IS NOT NULL OR name_en IS NOT NULL
  • check: phone IS NOT NULL OR email IS NOT NULL OR whatsapp IS NOT NULL OR wechat IS NOT NULL
TriggerOnRefuses with
matching_profile_changedafter update of versionkeeps derived rows in step
matching_profile_deletedafter deletekeeps derived rows in step
matching_profile_savedafter insertkeeps derived rows in step
member_search_profile_deleteafter deletekeeps derived rows in step
member_search_profile_insertafter insertkeeps derived rows in step
member_search_profile_updateafter updatekeeps derived rows in step
profiles_versionbefore update of versionprofile changed

media

Created in 0033_media.sql. 4 columns.

Columns of media
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
owner_user_idTEXT · required→ users.id
purposeTEXT · requiredcheck purpose IN ('profile_photo')
created_atTEXT · required

media_deletions

Object keys to remove from the bucket. A key is queued once; a failed delete is retried.

Created in 0033_media.sql. 4 columns.

Columns of media_deletions
ColumnTypeKeys and checksNote
keyTEXT · requiredprimary key
check key LIKE 'media/%'
queued_atTEXT · required
attemptsINTEGER · requiredDefault 0.
last_errorTEXT · may be null

media_objects

Created in 0033_media.sql. 5 columns, 1 rule.

Columns of media_objects
ColumnTypeKeys and checksNote
keyTEXT · requiredprimary key
check key LIKE 'media/%'
media_idTEXT · required→ media.id
sizeINTEGER · requiredcheck size > 0The square's side in pixels.
content_typeTEXT · requiredcheck content_type IN ('image/webp')
byte_sizeINTEGER · requiredcheck byte_size > 0
  • unique (media_id, size)

profile_photo_guard

Every insert fails with the constraint named photo_changed, which the upload turns into that refusal; it inserts only when its photo is not the current one. The table always stays empty.

Created in 0033_media.sql. 1 column, 1 rule.

Columns of profile_photo_guard
ColumnTypeKeys and checksNote
refusalTEXT · required
  • check photo_changed: refusal IS NULL

profile_photos

The member's photo, one per person like the profile, shown wherever their card is. An upload stores its objects before its batch runs, so the batch must fail (and the upload remove them) whenever the photo moved on meanwhile: a replacement updates only the row with the media id and version it read, and a guard insert then fails unless the photo is now the upload's (profile_photo_guard). A photo needs a profile, so one erased meanwhile fails too.

Created in 0033_media.sql. 4 columns, 3 triggers.

Columns of profile_photos
ColumnTypeKeys and checksNote
user_idTEXT · requiredprimary key
→ users.id
media_idTEXT · requiredunique
→ media.id
versionINTEGER · requiredcheck version >= 1
updated_atTEXT · required
TriggerOnRefuses with
profile_photos_keep_profilebefore update of media_idphoto profile required
profile_photos_need_profilebefore insertphoto profile required
profile_photos_versionbefore updatephoto_changed: a photo takes the next version
full-text index

Created in 0034_member_search.sql; changed in 0050_profile_stand_ins.sql. 8 columns.

Columns of member_search
ColumnTypeKeys and checksNote
user_idANY · may be null
chamber_idANY · may be null
namesANY · may be null
companiesANY · may be null
industriesANY · may be null
statedANY · may be null
offersANY · may be null
needsANY · may be null

member_search_source

view

Created in 0034_member_search.sql; changed in 0050_profile_stand_ins.sql. 8 columns.

Columns of member_search_source
ColumnTypeKeys and checksNote
user_idTEXT
chamber_idTEXT
names
companies
industries
stated
offers
needs

profile_stand_ins

view

Created in 0050_profile_stand_ins.sql. 8 columns.

Columns of profile_stand_ins
ColumnTypeKeys and checksNote
chamber_idTEXT
user_idTEXT
source
full_nameTEXT
phoneTEXT
companyTEXT
positionTEXT
industryTEXT

Matching, introductions and the board

matching_optins

Created in 0035_matching.sql. 13 columns, 2 triggers, 1 rule.

Columns of matching_optins
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
notice_versionTEXT · requiredcheck length(notice_version) BETWEEN 1 AND 40
opted_in_atTEXT · required
wanted_versionINTEGER · requiredcheck wanted_version >= 0The profile version the vectors should reflect: 0 while the person has no profile.
embedded_versionINTEGER · may be nullcheck embedded_version IS NULL OR embedded_version >= 1
embed_stateTEXT · requiredcheck embed_state IN ('pending', 'queued', 'embedded', 'skipped')pending: to be sent to the queue; queued: sent; embedded: vectors match wanted_version; skipped: nothing to embed or no embedding service bound (embed_note says which).
embed_noteTEXT · may be nullcheck embed_note IS NULL OR embed_note IN ('no_profile', 'nothing_to_match', 'not_configured')
queued_atTEXT · may be null
embedded_atTEXT · may be null
modelTEXT · may be null
overwrittenINTEGER · requiredcheck overwritten IN (0, 1)1 when a run for an older profile version wrote to the store after the run now in flight may have written: that run then records the opt-in as pending, not embedded, so it is embedded again. Default 0.
  • unique index matching_optins_one: (chamber_id, user_id)
TriggerOnRefuses with
matching_optins_goneafter deletekeeps derived rows in step
matching_optins_members_onlybefore insertmatching_members_only

matching_vector_deletions

Vector ids still to be deleted from Vectorize, in the chamber's namespace. No personal data.

Created in 0035_matching.sql. 2 columns.

Columns of matching_vector_deletions
ColumnTypeKeys and checksNote
vector_idTEXT · requiredprimary key
chamber_idTEXT · required

matching_vectors

The vectors held in Vectorize for an opt-in, by kind. Ids are random and reused when the profile is embedded again, so an upsert replaces the old vector.

Created in 0035_matching.sql. 6 columns, 3 triggers, 1 rule.

Columns of matching_vectors
ColumnTypeKeys and checksNote
vector_idTEXT · requiredprimary key
optin_idTEXT · required
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
kindTEXT · requiredcheck kind IN ('offers', 'needs')
profile_versionINTEGER · requiredcheck profile_version >= 1
  • unique (optin_id, kind)
TriggerOnRefuses with
matching_vectors_currentbefore insertmatching_overtaken
matching_vectors_current_updatebefore update of profile_versionmatching_overtaken
matching_vectors_need_optinbefore insertmatching_optin_gone

matching_dismissals

Someone the member dismissed is not suggested to them again for 90 days from the latest dismissal.

Created in 0042_suggestions.sql. 4 columns.

Columns of matching_dismissals
ColumnTypeKeys and checksNote
chamber_idTEXT · requiredprimary key
→ chambers.id
user_idTEXT · requiredprimary key
→ users.id
subject_user_idTEXT · requiredprimary key
→ users.id
dismissed_atTEXT · required

matching_reason_prompts

Every request to the language model for a reason, and what came back, kept for audit.

Created in 0042_suggestions.sql. 11 columns.

Columns of matching_reason_prompts
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
suggestion_idTEXT · required
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
subject_user_idTEXT · required→ users.id
modelTEXT · required
systemTEXT · required
promptTEXT · required
outputTEXT · may be nullThe answer as received (truncated), or null when the model could not be reached.
outcomeTEXT · requiredcheck outcome IN ('used', 'unusable', 'unavailable')used: the reason shown; unusable: the answer failed validation; unavailable: no answer.
asked_atTEXT · required

matching_suggestion_batches

Created in 0042_suggestions.sql. 8 columns, 2 triggers, 1 rule.

Columns of matching_suggestion_batches
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.id
weekTEXT · requiredcheck week GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'The Monday the week starts on, in the chamber's time zone (YYYY-MM-DD).
made_byTEXT · requiredcheck made_by IN ('schedule', 'member')
made_atTEXT · required
shownINTEGER · requiredcheck shown >= 0What the batch was made with: shown is how many were suggested; none, why there were none.
noteTEXT · may be nullcheck note IS NULL OR note IN ('not_shown', 'not_embedded', 'no_match')
  • unique index matching_suggestion_batches_weekly: (chamber_id, user_id, week) where made_by = 'schedule'
TriggerOnRefuses with
matching_suggestion_batches_hourlybefore insertsuggestions_made_recently
matching_suggestion_batches_opted_inbefore insertsuggestions_need_matching

matching_suggestions

Created in 0042_suggestions.sql. 15 columns, 3 rules.

Columns of matching_suggestions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
batch_idTEXT · required→ matching_suggestion_batches.id
chamber_idTEXT · required→ chambers.id
user_idTEXT · required→ users.idWho the suggestion is for, and who is suggested.
subject_user_idTEXT · required→ users.id
ordinalINTEGER · requiredcheck ordinal >= 0
scoreREAL · required
fitTEXT · requiredcheck fit IN ('they_offer', 'they_need')Which way the fit runs: they offer what the member needs, or they need what the member offers.
reason_sourceTEXT · requiredcheck reason_source IN ('model', 'template')
reason_enTEXT · may be nullcheck reason_en IS NULL OR length(reason_en) BETWEEN 1 AND 300
reason_zhTEXT · may be nullcheck reason_zh IS NULL OR length(reason_zh) BETWEEN 1 AND 300
template_kindTEXT · may be nullcheck template_kind IS NULL OR template_kind IN ('industry', 'they_offer', 'they_need')
template_paramTEXT · may be nullcheck template_param IS NULL OR length(template_param) BETWEEN 1 AND 200
reason_basisTEXT · may be nullcheck reason_basis IS NULL OR length(reason_basis) = 64For a model reason: a SHA-256 of what the model was shown of the suggested person's card. The reason is shown only while their card as the viewer sees it still gives the same; else the template is, worded from the cards as they are now, so a field hidden since is never shown through the reason.
dismissed_atTEXT · may be null
  • unique (batch_id, subject_user_id)
  • check: user_id <> subject_user_id
  • check: (reason_source = 'model' AND reason_en IS NOT NULL AND reason_zh IS NOT NULL) OR (reason_source = 'template' AND template_kind IS NOT NULL)

introductions

Created in 0043_introductions.sql; changed in 0047_introduction_outcomes.sql. 15 columns, 8 triggers, 3 rules.

Columns of introductions
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
requester_idTEXT · required→ users.id
recipient_idTEXT · required→ users.id
noteTEXT · requiredcheck length(note) BETWEEN 1 AND 500
sourceTEXT · requiredcheck source IN ('suggestion', 'directory', 'board')Where the request was made: a matching suggestion, the directory card, or a board post.
source_refTEXT · may be nullcheck source_ref IS NULL OR length(source_ref) BETWEEN 1 AND 100The suggestion or board post it was made from, when there is one.
statusTEXT · requiredcheck status IN ('pending', 'accepted', 'declined', 'expired', 'closed')pending: waiting for an answer; accepted / declined: answered; expired: not answered in 14 days; closed: one side's data was deleted while it was waiting.
requester_localeTEXT · requiredcheck requester_locale IN ('zh', 'en')The language each person is written to in, for email about the request.
recipient_localeTEXT · requiredcheck recipient_locale IN ('zh', 'en')
created_atTEXT · required
expires_atTEXT · required
decided_atTEXT · may be null
versionINTEGER · requiredcheck version >= 1Default 1.
requester_branch_idTEXT · may be null→ units.id
  • unique index introductions_open_pair: (chamber_id, min(requester_id, recipient_id), max(requester_id, recipient_id)) where status = 'pending'
  • check: requester_id <> recipient_id
  • check: (status = 'pending') = (decided_at IS NULL)
TriggerOnRefuses with
introductions_accept_eligiblebefore update of statusintroduction_not_eligible
introductions_accept_have_profilesbefore update of statusintroduction_no_profile
introductions_answered_oncebefore update of statusintroduction_changed
introductions_board_post_openbefore insertboard_post_unavailable
introductions_declined_recentlybefore insertintroduction_declined_recently
introductions_eligiblebefore insertintroduction_not_eligible
introductions_have_profilesbefore insertintroduction_no_profile
introductions_requester_branchafter insertkeeps derived rows in step

board_posts

Created in 0046_board.sql. 17 columns, 3 triggers, 3 rules.

Columns of board_posts
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
author_idTEXT · required→ users.id
kindTEXT · requiredcheck kind IN ('ask', 'offer')
titleTEXT · requiredcheck length(title) BETWEEN 1 AND 120
detailsTEXT · requiredcheck length(details) BETWEEN 1 AND 2000
searchTEXT · requiredThe title and details folded for search (NFKC, then lower-cased) by the app, as SQLite's lower() folds only ASCII: the search words are folded the same way.
industryTEXT · may be nullcheck industry IS NULL OR industry IN ('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U')An MSIC section from the author's profile, or none.
statusTEXT · requiredcheck status IN ('open', 'closed', 'removed')open: on the board until expires_at; closed: by the author; removed: by an admin, with a reason.
author_localeTEXT · requiredcheck author_locale IN ('zh', 'en')The language the author is written to in, for the email telling them of a removal.
created_atTEXT · required
expires_atTEXT · required
closed_atTEXT · may be null
removed_atTEXT · may be null
removed_byTEXT · may be null→ users.id
removal_reasonTEXT · may be nullcheck removal_reason IS NULL OR length(removal_reason) BETWEEN 1 AND 500
versionINTEGER · requiredcheck version >= 1Default 1.
  • check: expires_at > created_at
  • check: (status = 'closed') = (closed_at IS NOT NULL)
  • check: (status = 'removed') = (removed_at IS NOT NULL AND removed_by IS NOT NULL AND removal_reason IS NOT NULL)
TriggerOnRefuses with
board_posts_changed_oncebefore update of statusboard_post_changed
board_posts_eligiblebefore insertboard_not_eligible
board_posts_limitbefore insertboard_post_limit

introduction_outcomes

Created in 0047_introduction_outcomes.sql. 9 columns, 4 triggers, 2 rules.

Columns of introduction_outcomes
ColumnTypeKeys and checksNote
introduction_idTEXT · requiredprimary key
→ introductions.id
user_idTEXT · requiredprimary key
→ users.id
chamber_idTEXT · required→ chambers.id
asked_atTEXT · required
editable_untilTEXT · requiredAsked plus 60 days: an answer may be given or changed before then, never after.
ask_batchTEXT · requiredThe scheduled run that asked, so only the run that wrote the row tells the person.
answerTEXT · may be nullcheck answer IS NULL OR answer IN ('none', 'talks', 'deal')none: nothing came of it; talks: in talks; deal: a deal was done. Empty until answered.
answered_atTEXT · may be null
versionINTEGER · requiredcheck version >= 1Default 1.
  • check: (answer IS NULL) = (answered_at IS NULL)
  • check: editable_until > asked_at
TriggerOnRefuses with
introduction_outcomes_fixedbefore update of introduction_id, user_id, chamber_id, asked_at, editable_until, ask_batchintroduction_outcome_fixed
introduction_outcomes_lockedbefore update of answer, answered_atintroduction_outcome_locked
introduction_outcomes_partybefore insertintroduction_outcome_party
introduction_outcomes_versionedbefore updateintroduction_outcome_changed

Event photos

event_photo_guard

Guards an event photo command's batch repeats from what it read: it inserts here only when that no longer holds, and the insert fails with the constraint named after the refusal. The table always stays empty.

Created in 0040_event_photos.sql. 1 column, 2 rules.

Columns of event_photo_guard
ColumnTypeKeys and checksNote
refusalTEXT · required
  • check event_photo_changed: refusal <> 'event_photo_changed'
  • check event_photo_not_allowed: refusal <> 'event_photo_not_allowed'

event_photo_objects

Created in 0040_event_photos.sql. 5 columns, 1 rule.

Columns of event_photo_objects
ColumnTypeKeys and checksNote
keyTEXT · requiredprimary key
check key LIKE 'media/%'
photo_idTEXT · required→ event_photos.id
widthINTEGER · requiredcheck width > 0The stored width in pixels; the height follows the photo's shape.
content_typeTEXT · requiredcheck content_type IN ('image/webp')
byte_sizeINTEGER · requiredcheck byte_size > 0
  • unique (photo_id, width)

event_photos

Created in 0040_event_photos.sql. 15 columns, 6 triggers, 5 rules.

Columns of event_photos
ColumnTypeKeys and checksNote
idTEXT · requiredprimary key
chamber_idTEXT · required→ chambers.id
event_idTEXT · required
uploader_user_idTEXT · may be null→ users.idNull once the uploader's data was erased while the photo was approved.
uploaded_asTEXT · requiredcheck uploaded_as IN ('staff', 'attendee')'staff' photos are published at once; 'attendee' photos wait for an admin.
statusTEXT · requiredcheck status IN ('pending', 'approved', 'rejected')
showcaseINTEGER · requiredcheck showcase IN (0, 1)Default 0.
caption_zhTEXT · requiredcheck length(caption_zh) <= 300Default ''.
caption_enTEXT · requiredcheck length(caption_en) <= 300Default ''.
uploaded_atTEXT · required
moderated_byTEXT · may be null→ users.id
moderated_atTEXT · may be null
showcased_byTEXT · may be null→ users.id
showcased_atTEXT · may be null
versionINTEGER · requiredcheck version >= 1Default 1.
  • (chamber_id, event_id) → events (chamber_id, id)
  • check: showcase = 0 OR status = 'approved'
  • check: (status = 'pending') = (moderated_at IS NULL)
  • check: (moderated_at IS NULL) = (moderated_by IS NULL)
  • check: (showcase = 1) = (showcased_at IS NOT NULL) AND (showcased_at IS NULL) = (showcased_by IS NULL)
TriggerOnRefuses with
event_photos_event_openbefore insertevent_photo_event_closed
event_photos_fixedbefore update of id, chamber_id, event_id, uploaded_as, uploaded_atan event photo stays with its event
event_photos_never_pending_againbefore update of statusevent_photo_changed: a decided photo is not pending again
event_photos_pending_limitbefore insertevent_photo_limit_reached
event_photos_uploader_unlinked_onlybefore update of uploader_user_idan event photo stays with its event
event_photos_versionbefore update of versionevent_photo_changed: a photo takes the next version

Migrations

Applied in file order, to the remote database before the code that needs them is deployed. Numbers missing from the sequence have no file in the repository.

FileWhat it does
0001_foundation.sqlUsers, platform operators, chambers, and the records every capability leaves behind.
0002_sign_in.sqlEmail sign-in codes, sessions and privacy consent.
0003_structure.sqlThe tree of units inside a chamber, and the admins appointed over it.
0004_membership.sqlApplications to committees, chamber memberships, committee memberships, and the inbox and outbox that tell people about decisions.
0005_audit_scope.sqlEach audit entry records the unit a scoped capability acted at, so a chamber's log can be shown to the admins of that unit and the units above it.
0006_outbox_sending.sqlAdds the 'sending' state, so a delivery can claim an email before sending it. SQLite cannot alter a CHECK constraint, so the table is rebuilt.
0007_positions.sqlPositions: an office a member holds in one committee for a dated term. The holders of current positions in a committee are its Exco. Whether a position is current is worked out when it is read, from its dates in the chamber's time zone, so nothing needs to run when a term lapses.
0008_tiers_points.sqlTiers, points and a member's standing in a chamber. Concurrency (D1 has no interactive transactions, so a command reads before its batch): * memberships.standing_version is bumped by every change to a member's points or tier, and the trigger refuses a bump from a stale reading, so two changes to one member computed from the same reading cannot both commit. * points_settings.ladder_version is bumped when the ladder changes. A points change asserts the ladder it used is still current (ladder_check), and bumps points_version; a ladder edit asserts no points changed since it read them (points_check). So a raise is never computed from a ladder or totals that changed underneath it. Each conflict aborts the batch with 'standing changed', and the command is refused with try_again.
0009_membership_lifecycle.sqlLeaving, suspension and ending a membership. A membership moves only between active, suspended, resigned and terminated; resigned and terminated are final. Every move is recorded first in membership_transitions, and a committee membership, once ended, stays ended.
0010_start_tier_open_only.sqlA new membership starts at the lowest tier. Only an open membership gets one: an ended membership is final and cannot be updated (0009), so the starting tier must not be written to it.
0011_visibility.sqlWho may see what in a chamber: a table of the fixed gateable items against audiences. A chamber with no rows uses the defaults in code (src/visibility.ts). Saving the table replaces every row at once, so a saved chamber holds one row per item and audience. A tier with no row of its own takes the ticks of the tier just below it on the ladder, so a tier added later inherits; a retired tier's rows are ignored. Concurrency: each save bumps visibility_settings.version from the version the admin opened, and the trigger refuses a save from a stale table, so two admins saving at once cannot silently overwrite each other. The first save of a chamber inserts version 1, and a second first save collides on the primary key.
0012_data_requests.sqlRequests from a person about the data held on them: deleting what one chamber holds, or erasing their whole account. Each chamber is the data controller and answers within 21 days; the platform operator erases accounts. A request is received, then completed or refused, once. Every move is recorded first in data_request_transitions and takes the request's next version, so two admins deciding one request at once cannot both succeed.
0013_news_sources.sqlNews sources and the headlines fetched from them, for the daily digest. Only metadata is kept: a headline, its link, when it was published and where it came from. A feed's own short description is kept only for a source whose terms allow it (the CHECK on news_items refuses one otherwise). Article bodies are never stored. Concurrency (D1 has no interactive transactions): * news_sources.version is bumped by every operator edit, and the trigger refuses a bump from a stale reading, so two edits made from the same reading cannot both commit. * A fetch claims a source by moving next_due_at forward with a guarded UPDATE, so two runs at once cannot both fetch it. * news_items.url_key is unique, so a headline is stored once however often it is fetched.
0014_member_import.sqlImporting existing members from a spreadsheet. An admin uploads a file; it is checked and kept as a preview for thirty minutes, in chunks of at most a hundred rows. Each chunk is imported by one command, so a D1 batch stays within its limits. Concurrency: every run bumps member_imports.version from the value it read, and the trigger refuses a stale bump, so two runs computed from the same reading cannot both commit. A chunk, once imported, cannot be imported again.
0015_scheduled_runs.sqlEach run of scheduled work (outbox retries, the daily backup), so the operator's health page can show when it last ran and how it went.
0016_news_analysis.sqlWhat the platform worked out about each news item: its topics and relevance score, and the machine-written text shown with it (a two-sentence summary in Chinese and English for a source whose terms allow it; otherwise only the headline machine-translated into the interface languages). Every item gets a row in the same batch that stores it, classified by the keyword rules; the scheduled analysis then asks the model and replaces the classification when the answer is valid. A summary is never kept for a headline-and-link source. The triggers refuse one unless both the item (as fetched) and its source (now) allow summaries, and a source changed to headline and link loses the summaries of its items at once. Concurrency (D1 has no interactive transactions): a run claims an item by bumping `attempts` with a guarded UPDATE, and writes its answer only while `attempts` still holds the value it set, so two runs at once cannot both ask the model about one item or overwrite each other.
0017_chamber_brands.sqlA chamber's brand, and the addresses (hosts) that show it. A chamber with no brand row is shown with its own name in both languages, the first character of that name as its mark, and the platform's default colour. Concurrency: each save bumps the version from the one the editor opened, and the trigger refuses a save from a stale form. The first save inserts version 1, and a second first save collides on the primary key.
0018_positions_end_with_place.sqlA position ends when its holder's place in the committee ends: when they leave, are removed, or their membership ends. Until now such a position stayed open, so it came back into the Exco when the holder rejoined. The ending is written by the same batch that ends the place; ended_how says it ended that way rather than by an admin's decision, and end_reason holds the same code (the interface shows it from its catalogue).
0019_standing_guards.sqlGuards for points, tiers and committee places that a command's reading alone cannot hold, because D1 reads before it writes (see 0008). Each abort names a conflict the command turns into a try_again refusal; on the retry the command reads the new state and refuses or proceeds by it.
0020_erase_imported_details.sqlImported details can be erased. 0014 kept an imported member's row exactly as imported, refusing any update. A completed deletion request (one chamber's, or a whole account erasure) must remove the person's details from it, so the only update now allowed is the erasure itself: every personal field set to NULL, and nothing else changed. Deleting a row is still refused: the row also records how the membership came about and the language to write to the member in.
0023_news_descriptions_withdrawn.sqlA source whose summary terms are withdrawn loses the feed descriptions stored under them. Migration 0016 clears the machine summaries when terms move to headline and link; the publisher's own descriptions stayed stored (and shown) after permission was withdrawn. The items keep their own terms, those in force when they were fetched, so 0026 can still ask for summaries again if the terms come back; the CHECK on news_items allows a summary_allowed item with no description.
0026_news_terms_restored.sqlA source moved back to allowing summaries (after headline and link) has its items summarised again. Migration 0016 resets a source's items only when its summaries are withdrawn; without this the items it had translated stayed 'done' with no summary, so they were never asked about again and showed "summary pending" for good. Only items fetched under summary terms are reset: an item's own terms are those in force when it was fetched.
0028_events.sqlEvents, their reach, and the waves in which registration opens. An event belongs to one unit (a committee, a branch or the chamber) and is offered to a reach: the organising committee, its whole branch, or the whole chamber. The reach never goes above the owner's level: a branch committee reaches its committee or its branch; a national committee its committee or the chamber; a branch its branch; the chamber the chamber. reach_unit_id is the unit the reach names, checked here. Waves are an ordered list of an audience and the time registration opens to it. Saving waves replaces the event's whole list. Concurrency: every change to an event (its details, status or waves) bumps events.version from the version the admin opened, and the trigger refuses a change from a stale reading, so two admins editing at once cannot overwrite each other. The chamber's default pattern is guarded the same way.
0029_profiles.sqlA member's profile: one per person, used in every chamber they belong to. The business card (a name, at least one company with its industry and the person's position there, and at least one way to reach them) is required from the first save. Who may see each optional field is the member's choice, kept in visibility_json as field -> 'members' | 'hidden'; the names and each company's name are always shown to members. The read path is profileCard in src/capabilities/profiles.ts. Concurrency: each save bumps profiles.version from the version the member opened, and the trigger refuses a save from a stale profile, so two saves at once cannot silently overwrite each other. The first save inserts version 1; a second first save collides on the primary key. Companies are replaced with the profile in the same batch. Personal data: exported by export.mine, deleted by an account erasure, and by a chamber's deletion when no other chamber still holds the person (capabilities/data-requests.ts).
0030_news_digest.sqlThe chamber's daily news digest: its settings, the admins' editorial changes, and the snapshot assembled each day at the chamber's chosen time. A digest is assembled once per day for the chamber, and also for each branch (and committee) when the chamber says it varies by them. A variant is the chamber's digest with the editorial changes made at its unit and above it; the nearest unit's change to an item wins. Nothing here is about a member: the snapshot holds news headlines and what admins wrote for the chamber, and the editorial tables name the admin who acted by user id only, so an erased account shows as a deleted person wherever its email is joined in. Concurrency (D1 has no interactive transactions): every write to one chamber's digest for one day (an assembly or an editorial change) bumps that day's row in digest_days, and the trigger refuses a bump from a stale reading. So an edit made while the digest is being assembled, or two edits made from the same reading, cannot both commit; the loser is refused (or the assembly tries again) instead of overwriting the other. The unique index on snapshots keeps one per unit and day.
0031_push.sqlWeb push: each device a person allowed push on, their choice of which notices reach them on which channel, and a 'push' channel in the outbox.
0032_event_registrations.sqlRegistrations for events: a place taken, a place on the waitlist, or a place offered from it. A registration holds the member and their guests (names, as typed), and so takes 1 + the number of guests places. Registered and offered rows hold places; together they never exceed the event's capacity, which the triggers below check inside the same write, so two people taking the last place at once cannot both have it, and two offers cannot be made for one place. A person has at most one live registration (registered, waitlisted or offered) per event. Statuses move only forward: registered -> cancelled | released waitlisted -> offered | cancelled | released offered -> registered | declined | expired | released 'released' is what a cancelled event does to every live registration. Concurrency: every change bumps version from the version read, and the trigger refuses a change from a stale reading. An offer is taken only before it expires, checked against the time the change is written. Registrations are personal data: a data export includes them, and both erasures delete them.
0033_media.sqlMedia: images people upload, such as profile photos (and later event photos and chamber logos). The bytes live in the MEDIA R2 bucket, never in the database. Each upload is one `media` row under an opaque id, and one `media_objects` row per stored object (each fixed size is its own object, keyed media/<id>/<size>.webp). See src/media.ts. R2 cannot be written in the same batch as D1, so objects are written before the batch that records them, and removed afterwards through `media_deletions`: a change that drops media queues its object keys in the same batch, and the queue is worked off right after the change commits and again by the scheduled handler, until R2 confirms each delete. Personal data: a profile photo is exported by export.mine and deleted by both erasures (capabilities/data-requests.ts). Backups copy these rows but never the R2 objects.
0034_member_search.sqlThe member directory's search index: one row per saved profile, holding only what a chamber's members may see of it. Search reads this table, never the profile, so a field a member hides can never be matched. member_search_source says what a profile puts in the index: its names and company names (always shown to members), and its companies' industries as tokens msic-a ... msic-u, only while the member shows their industry ('members' is the default, as in DEFAULT_PROFILE_VISIBILITY). Triggers on profiles and profile_companies rewrite the person's row from it on every change, in the same statement as the change, so the index cannot lag behind a save or an erasure. A restore rebuilds the whole index from the view (src/backup.ts), so the index is never copied into a backup. The trigram tokenizer matches any run of three or more characters, which works for Chinese names, which have no spaces between words; shorter words use GLOB on this same table (src/capabilities/directory.ts). Which chamber a person shows in, and whether their membership is active, is decided when the index is queried. Personal data: a copy of part of the profile, removed with it by the triggers.
0035_matching.sqlBusiness matching: a member's opt-in per chamber, and the record of the vectors embedded for it. A member opts in to matching in one chamber after reading the plain explanation, recorded with the notice version. While opted in, text built from the member-visible fields of their profile (what they offer and need, each company's industry and description) is embedded and stored in Vectorize, in the chamber's own namespace: one vector for what they offer and one for what they need. The embedding runs off a queue (capabilities/matching.ts, platform.ts). State is kept here; the vectors themselves live in Vectorize. Each opt-in row says which profile version its vectors should reflect (wanted_version) and which they do (embedded_version). A queue message carries the opt-in id and the profile version it was sent for, so a retried or overtaken message is dropped. Vectors are deleted on opt-out, when the membership ends (resigned or terminated), when the profile is deleted, and on either erasure: whatever removes the opt-in row moves its vector ids to matching_vector_deletions in the same batch (trigger below), and the platform deletes them from Vectorize and then the rows. That table holds only random ids and a chamber id. Personal data: matching_optins and matching_vectors are exported by export.mine and deleted by both erasures (capabilities/data-requests.ts).
0036_digest_email.sqlThe news digest by email (#50). A member chooses, per chamber, whether the chamber's digest is emailed to them daily or weekly, and in which language. No row means no email. Both tables hold personal data: they are in the personal data export, and a chamber's deletion of the person's data (or an account erasure) removes them.
0037_deletion_reaches_everything.sqlWhat a completed deletion must also reach.
0038_news_translation_recheck.sqlNews items to check once more under the translation rules of ticket #61: each item's language is read from its own text (a source labelled English may publish Malay headlines), and a stored headline translation that changed a number, carries kana or stray Latin words, or is missing for a language the item now needs is cleared and asked for again. The rules are code (news/language.ts, news/translation-check.ts), not SQL, so this migration only queues every stored item; the news analysis run drains the queue a bounded number at a time before it asks the model, and removes each item as it is checked, so each is checked once. Holds no personal data.
0039_event_check_ins.sqlChecking people in at the door of an event. A check-in records that the person holding a registration arrived: who checked them in (event staff, or the person themselves), how (scanning their member card, the person scanning the venue's code, ticking their name on the list, or as a walk-in) and when. `checked_in_at` is when it happened on the device; `recorded_at` is when the server received it, later for a check-in queued on a device without a connection. Each check-in carries the key its device generated, unique, so a check-in sent twice (a queue replayed after a dropped connection) is recorded once. A registration has at most one check-in that stands; an undone check-in keeps its row, with who undid it, when and why, and the person may be checked in again. A walk-in is a registration made at the door, while the event is on and has space: it holds one place like any other, so the capacity triggers of 0032 still hold, and only the rule that registration closes when the event starts is lifted for it. Check-ins are personal data: a data export includes them, and both erasures delete them (before the registrations they belong to).
0040_event_photos.sqlEvent photos: uploaded by event staff (published at once) or submitted by attendees (waiting for an admin), moderated, and picked by the publisher for the public showcase of past events. The bytes live in the MEDIA bucket through the media module (src/media.ts): each photo is stored at fixed widths (256, 1024, 2048) keeping its shape, as WebP with no metadata, under media/<photo id>/<width>.webp. The photo's id is its media id. Event photos keep their own rows here rather than in `media`: that table's purpose check and its required owner could only change by rebuilding a table other tables reference, and an event photo outlives the link to whoever uploaded it. Dropped objects go through the shared `media_deletions` queue. Statuses: pending (an attendee's photo, waiting), approved, rejected. An admin may approve a rejected photo or reject an approved one; nothing returns to pending. Only an approved photo can be in the showcase, and rejecting it takes it out. Concurrency: every moderation, caption or showcase change bumps version from the version read, and the trigger refuses a stale one. An upload stores its objects before its batch runs, so its insert is conditional on what made the uploader allowed (their admin grant, or their registration) still holding, and a guard then fails the batch if the photo was not written. Personal data: who uploaded a photo is the uploader's. Export lists their photos. Both erasures delete their photos still pending or rejected (with the objects) and unlink them from approved ones, which stay as the chamber's record of the event, shown as uploaded by a deleted person. The people in a photo are not identified anywhere. Backups copy these rows but never the R2 objects.
0041_event_notices.sqlEvent reminders and wave-opening notices (#38). The day before an event, everyone holding a place (registered, or offered one from the waitlist) is reminded; when a wave opens to a member who chose to hear about it, they are told registration is open to them. Both are sent by the scheduled handler.
0042_suggestions.sqlWeekly matching suggestions, each with a one-line reason, and the people a member dismissed. Each week (from Monday, in the chamber's time zone) the scheduled run gives each opted-in member a batch of a few suggested members, found by nearest neighbours between their needs and others' offers and the reverse (capabilities/suggestions.ts). A member who has none showing may ask for a new batch, at most once an hour. The latest batch is what the member sees, re-checked on every read against who may still be suggested. Each suggestion carries a reason in both interface languages: written by a language model from the two cards as the viewer sees them (reason_source 'model'), or, when the model is unavailable or its answer unusable, a template naming the shared industry or the need (reason_source 'template', worded by the catalogues). Every prompt sent to the model and its answer is kept in matching_reason_prompts for audit. Personal data: all four tables name two people. They are in the viewer's export, and either person's erasure (one chamber's or the whole account) deletes every row naming them (capabilities/data-requests.ts).
0043_introductions.sqlIntroductions: a member asks to be introduced to another member of the same chamber, with a short note. The other person accepts or declines; a request not answered in 14 days expires on the schedule. Once accepted, each sees the other's contact details on their card, even those they hide from members in general (profileCard in capabilities/profiles.ts). Both people must be active members of the chamber and opted in to matching there, when the request is made and when it is accepted (triggers below). Between two people in one chamber only one request is open at a time, in either direction (the unique index). A declined request keeps its requester from asking the same person again for 90 days, counted from the decline. A person whose data is deleted (by the chamber or with their account) leaves the record to the other side as "a deleted person": requests still open are closed, the note they wrote is replaced with a placeholder, and contacts are no longer shared, even if they join again. Personal data: the note. Exported by export.mine and cleared by both erasures (capabilities/data-requests.ts).
0044_chamber_logos.sqlA chamber's logo is uploaded and kept in the MEDIA R2 bucket through the media module (src/media.ts), no longer fetched from an address the chamber typed. 1. The typed address goes. Every brand row keeps everything else; a chamber that had a logo address shows its mark until an admin uploads the logo.
0045_event_staff.sqlEvent staff, an event's completion, and organising points. 1. Event staff: the publisher (an admin whose reach covers the event's owner) names active members of the chamber to check people in at one event. A person is named at most once at a time; removing them keeps the row, with who removed them and when, and they may be named again. Naming needs a published event that has not completed, and an active member. 2. Completion: an event is completed one day after it ends (the schedule), or when the publisher marks it so after it ends. It happens once, and is final: the door closes for everyone (no check-in is written or undone afterwards, so who attended is fixed), the event can no longer be cancelled, and its time, venue, places, reach and waves are fixed. A completion by the schedule has no person. 3. Organising awards: the event's admins award organising points to a member who helped, spoke or volunteered, once per person per event, ever: removing an award keeps its row, with who removed it, when and why, and the points are taken back as a correction. Staff and organising awards are personal data: a data export includes them, and both erasures delete them.
0046_board.sqlThe asks and offers board: a member of a chamber posts what they are looking for (an ask) or what they can provide (an offer), with a title, details, optionally one of the industries on their profile, and an expiry of at most 90 days. Other members browse and search the chamber's board and answer a post by asking to be introduced to its author (introductions, source 'board', source_ref the post's id). The author closes a post when it is done; a chamber admin or the platform operator removes one, with a reason the author is told. A post is on the board while it is open and its expiry has not passed (expiry is read against the clock, not stored as a status). Closed, removed and expired posts stay in the author's own list with their state. Posting is for active members of the chamber with matching on there (trigger below), and each has at most five posts on the board at a time in a chamber (the limit trigger), so a raced second post cannot slip past the count. An introduction asked from the board names a post that is still on it, by the person asked, as the request commits (trigger below), so a close or removal raced past the request's read still stops it. Personal data: the title, details (and their search form) and removal reason, which are about the author. Exported by export.mine; both erasures (the chamber's and the account's) delete the author's posts (capabilities/data-requests.ts).
0047_introduction_outcomes.sqlIntroduction outcomes: thirty days after an introduction is accepted, each of its two people is asked once, in their inbox (and by push as they chose), whether business came of it: none, in talks, or a deal done. Each answers once per introduction and may change the answer for 60 days from being asked; after that it is locked (a trigger). The schedule asks; the primary key keeps it to once per person and introduction even when two runs overlap. Chamber totals count introductions by the month they were requested, in the chamber's time zone, and by the branch of the requester's membership when they asked, kept here on the introduction (requester_branch_id) so a member who later leaves and joins another branch does not move it. Personal data: each person's answer. Exported by export.mine and deleted by both erasures (capabilities/data-requests.ts); totals are then counted from the answers that remain.
0048_operations_and_log_reach.sqlEvery transition names the operation that made it, as the history of an application, a membership, a committee place, a tier or a data request is meant to. The executor's operation id is written in the same batch as the transition; rows made before this migration, and by scheduled work, have none.
0049_wave_pattern_audiences.sqlThe chamber's default release pattern covers every audience, not only the public (#27): the organising Exco, the tiers (each from highest to lowest), Friends and the public can each be included or left out. Existing patterns keep every audience they had: the new flags start on. The organising Exco wave applies only to a committee's event, the public only to a chamber-wide one, as before. A pattern must open to the tiers or to Friends, so every event drawn from it has a wave whatever its owner and reach. Holds no personal data.
0050_profile_stand_ins.sqlProfiles, completed: stand-ins for members who have not saved a profile, and industry tags on what a member offers and needs. Industry tags: what a business offers and what it is looking for may each be tagged with MSIC sections (A ... U, as profile_companies.industry), kept as JSON arrays on the profile. They follow the visibility of the offers and the needs field (src/capabilities/profiles.ts). Stand-ins: until a member saves a profile, the details they gave the chamber stand in for it: their latest approved application to that chamber, else the row they were imported with, as member.list reads them. profile_stand_ins holds one row per person and chamber, only while the person has no profile and the details are not erased. Of those details, members see the name, the company, its industry and the position as given (the industry is free text there); the phone stays hidden, as contacts on a profile start hidden. An import may leave out the name: such a member stands in by their company, with no name (the directory lists them after everyone named); a row with neither, as erasure leaves it, does not. The directory's search index (0034) is rebuilt with a chamber column: a profile's row has none and applies in every chamber; a stand-in's row is for its chamber. Triggers on applications and imported_member_details keep stand-in rows in step, and a profile's first save drops them (its insert trigger rewrites every row of the person). The index still holds only what members may see: names, company names, shown industries, the stated industry of a stand-in, and the offer and need tags while those fields are shown. A restore rebuilds it from member_search_source (src/backup.ts). Personal data: the tags are part of the profile and go with it; stand-in rows are copies of application and import details, rewritten when those are erased.
0051_application_push_choices.sqlBefore notice.setPreferences ignored kinds the person is not offered, the settings form sent a choice about new applications to review (applicationReceived) for every member, so saving it as a non-admin stored that kind as turned off, and it stayed off once they were appointed. Only admins are offered that choice, so a stored one belonging to someone with no current admin grant was never theirs to make: it is cleared, and they get the default when appointed. A choice an admin made (stored while they hold a grant) is kept.
0052_news_title_key.sqlEach stored headline checks that no other source stored the same headline in the last few days (news.ts, fetchOne), so two queue consumers fetching at once cannot both store one story.