Sync company employee information from Lark into DB tables that can be joined by email or GitHub account for cost attribution, CI ownership, and issue tracking.
The schema keeps the first version small:
- no snapshots
- no identity table
- no closure table
- no multi-group relation table for now
- a small change-event table for weekly roster notifications
Hierarchy queries use materialized path columns.
Stores one row per employee.
CREATE TABLE roster_employees (
id BIGINT NOT NULL AUTO_INCREMENT,
lark_id VARCHAR(128) NOT NULL,
name VARCHAR(255) NOT NULL,
en_name VARCHAR(255) NULL,
employee_no VARCHAR(64) NULL,
email VARCHAR(255) NULL,
github_id VARCHAR(255) NULL,
join_time DATETIME NULL,
manager_id BIGINT NULL,
manager_path VARCHAR(1024) NULL,
group_id BIGINT NULL,
group_path VARCHAR(1024) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_seen_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_roster_employees_lark_id (lark_id),
UNIQUE KEY uk_roster_employees_email (email),
UNIQUE KEY uk_roster_employees_github_id (github_id),
KEY idx_roster_employees_employee_no (employee_no),
KEY idx_roster_employees_manager (manager_id),
KEY idx_roster_employees_group (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;Field notes:
id: internal numeric primary key for joins.lark_id: Larkunion_id, used for sync upsert.en_name: English display name from Larken_name, used as a readable identity hint.employee_no: employee number from Lark, used for HR and cost data reconciliation.email: company email. Empty values from Lark must be normalized toNULL.github_id: GitHub account value from Lark. This is expected to be the GitHub login/name if Lark stores that as the custom field.join_time: employee join time from Larkjoin_time, stored as UTCDATETIME.manager_id: direct manager, referencesroster_employees.id.manager_path: ancestor manager chain, stored as/ceo_id/cto_id/direct_manager_id/. It does not include the employee's ownid.group_id: employee's primary/direct group, usually the leaf group from Lark.group_path: full group path for the primary group, copied fromroster_groups.path.is_active: active employee flag.last_seen_at: last successful sync time when this employee was returned by Lark.
Stores one row per Lark group/department.
CREATE TABLE roster_groups (
id BIGINT NOT NULL AUTO_INCREMENT,
lark_group_id VARCHAR(128) NOT NULL,
parent_id BIGINT NULL,
name VARCHAR(255) NOT NULL,
manager_id BIGINT NULL,
path VARCHAR(1024) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_seen_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_roster_groups_lark_group_id (lark_group_id),
KEY idx_roster_groups_parent (parent_id),
KEY idx_roster_groups_manager (manager_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;Field notes:
id: internal numeric primary key.lark_group_id: Lark group/department ID used for sync upsert.parent_id: parent group, referencesroster_groups.id.manager_id: group manager, referencesroster_employees.id.path: materialized group path including itself, for example/root_id/dept_id/team_id/.is_active: active group flag.last_seen_at: last successful sync time when this group was returned by Lark.
Stores employee-level changes detected during sync. This table is intentionally not a full snapshot; it only records the changes needed for weekly notifications.
CREATE TABLE roster_employee_change_events (
id BIGINT NOT NULL AUTO_INCREMENT,
event_type VARCHAR(32) NOT NULL,
employee_lark_id VARCHAR(128) NOT NULL,
employee_name VARCHAR(255) NOT NULL,
employee_email VARCHAR(255) NULL,
manager_name VARCHAR(255) NULL,
manager_email VARCHAR(255) NULL,
group_name VARCHAR(255) NULL,
group_path VARCHAR(1024) NULL,
previous_group_name VARCHAR(255) NULL,
previous_group_path VARCHAR(1024) NULL,
event_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_roster_employee_change_events_event_at (event_at),
KEY idx_roster_employee_change_events_type_time (event_type, event_at),
KEY idx_roster_employee_change_events_employee (employee_lark_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;Field notes:
event_type: one ofhire,leave, orgroup_change.employee_*,manager_*, andgroup_*: denormalized display fields for notifications.previous_group_*: previous department fields forgroup_changeevents.event_at: sync time when the change was detected.
Find all direct and indirect reports of a manager:
SELECT *
FROM roster_employees
WHERE manager_path LIKE CONCAT('%/', :manager_id, '/%')
AND is_active = 1;Find a manager and all direct or indirect reports:
SELECT *
FROM roster_employees
WHERE (id = :manager_id OR manager_path LIKE CONCAT('%/', :manager_id, '/%'))
AND is_active = 1;Find employees under a group, including nested child groups:
SELECT *
FROM roster_employees
WHERE group_path LIKE CONCAT('%/', :group_id, '/%')
AND is_active = 1;Find child groups under a group:
SELECT *
FROM roster_groups
WHERE path LIKE CONCAT('%/', :group_id, '/%')
AND is_active = 1;The sync job should run as a full refresh:
Implementation should run Pass 1 and Pass 2 in a single DB transaction:
with engine.begin() as conn:
...Pass 1:
- Fetch all Lark groups and employees.
- Upsert groups by
lark_group_id, with reference columns set toNULLfor now. - Upsert employees by
lark_id, with reference columns set toNULLfor now. - Update
last_seen_atfor every group and employee returned by Lark.
Pass 2:
- Build in-memory maps from Lark IDs to internal numeric IDs.
- Resolve group
parent_id. - Resolve group
manager_id. - Resolve employee
manager_id. - Resolve employee
group_id. - Build
roster_groups.pathfrom group parent chains. - Build
roster_employees.manager_pathfrom employee manager chains. - Copy the employee primary group's
pathintoroster_employees.group_path.
Inactive handling:
- A failed sync must not update
is_active. - Employees and groups should only be marked inactive after they have not been seen for
a grace period, for example
last_seen_at < NOW() - INTERVAL 2 DAY. - Empty
emailandgithub_idvalues from Lark must be stored asNULL, not empty strings, so nullable unique keys do not conflict. - Duplicate
emailorgithub_idvalues in one Lark fetch must be stored asNULLfor all employees with that duplicated value. These columns are join keys; keeping ambiguous values is worse than leaving the join key empty. - TODO: During implementation, verify whether Lark
employee_nois globally unique and whether empty values are returned as empty strings. If it is stable and unique, changeidx_roster_employees_employee_noto a unique key. - Because
group_pathis denormalized onto employees, every full sync must refresh it after group paths are rebuilt. - Employee change events are recorded after reference fields and inactive flags are refreshed:
a new or reactivated employee emits
hire, a stale employee newly marked inactive emitsleave, and a primary group change emitsgroup_change.
The first version stores only one primary group per employee. This is enough for the initial cost and ownership joins when we choose the leaf/default group from Lark.
If cost attribution later needs multiple direct groups per employee, add a relation table then. Do not add it before that need is real.