Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions bin/migrate-user-ids
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
#!/usr/bin/env perl
use v5.36.0;

# PODNAME: migrate-user-ids

# Migration: convert users table to integer primary key, and remap all
# username-keyed JSON state blobs to use integer user ids instead.
#
# Run this once against your production database before deploying the code
# changes that expect the new schema.
#
# Usage: bin/migrate-user-ids --db synergy.sqlite
# or: bin/migrate-user-ids --config synergy.toml

use DBI;
use Getopt::Long::Descriptive;
use JSON::MaybeXS;
use Path::Tiny;

my ($opt, $usage) = describe_options(
'%c %o',
[ 'db|d=s', 'path to synergy.sqlite' ],
[ 'config|c=s', 'path to synergy config file (to find the db path)' ],
[ 'dry-run|n', 'print what would be done without making changes' ],
[ 'help|h', 'print usage', { shortcircuit => 1 } ],
);

print($usage->text), exit if $opt->help;

my $dbfile;
if ($opt->db) {
$dbfile = $opt->db;
} elsif ($opt->config) {
my $text = path($opt->config)->slurp;
($dbfile) = $text =~ /state_dbfile\s*=\s*['"]?([^'"\n]+)['"]?/;
die "Couldn't find state_dbfile in config\n" unless $dbfile;
} else {
$dbfile = 'synergy.sqlite';
warn "No --db or --config given, trying $dbfile\n";
}

die "Database file not found: $dbfile\n" unless -f $dbfile;

my $dbh = DBI->connect(
"dbi:SQLite:dbname=$dbfile",
undef, undef,
{ RaiseError => 1, AutoCommit => 1 },
) or die $DBI::errstr;

$dbh->do('PRAGMA foreign_keys = OFF');

# ---------------------------------------------------------------------------
# 1. Check current schema
# ---------------------------------------------------------------------------

my ($users_pk) = $dbh->selectrow_array(
q{SELECT type FROM pragma_table_info('users') WHERE name = 'id'}
);

if ($users_pk) {
say "users table already has an 'id' column — migration may have already run.";
say "Check the schema before re-running.";
exit 1;
}

# ---------------------------------------------------------------------------
# 2. Read current users and build username -> id mapping (we assign ids now)
# ---------------------------------------------------------------------------

my @users = @{ $dbh->selectall_arrayref(
'SELECT username, is_master, is_virtual, is_deleted FROM users ORDER BY rowid',
{ Slice => {} },
) };

my @identities = @{ $dbh->selectall_arrayref(
'SELECT username, identity_name, identity_value FROM user_identities',
{ Slice => {} },
) };

say "Found " . scalar(@users) . " user(s) and " . scalar(@identities) . " identity row(s).";

if ($opt->dry_run) {
# These ids are just a preview based on current rowid order; the real run
# lets SQLite assign ids via INSERT, so the final numbering may differ.
say "[dry-run] Would assign ids (approximate — SQLite picks the actual ids):";
my $id = 1;
for my $u (@users) {
say " $id: $u->{username}";
$id++;
}
}

# ---------------------------------------------------------------------------
# 3. Rebuild users and user_identities tables
# ---------------------------------------------------------------------------

unless ($opt->dry_run) {
$dbh->begin_work;

eval {
$dbh->do('DROP TABLE IF EXISTS user_identities');
$dbh->do('DROP TABLE IF EXISTS users');

$dbh->do(q{
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
is_master INTEGER DEFAULT 0,
is_virtual INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0
)
});

$dbh->do(q{
CREATE TABLE user_identities (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
identity_name TEXT NOT NULL,
identity_value TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT constraint_user_identity UNIQUE (user_id, identity_name),
UNIQUE (identity_name, identity_value)
)
});

my $ins_user = $dbh->prepare(
'INSERT INTO users (username, is_master, is_virtual, is_deleted) VALUES (?,?,?,?)'
);
for my $u (@users) {
$ins_user->execute($u->{username}, $u->{is_master}, $u->{is_virtual}, $u->{is_deleted});
}

$dbh->commit;
};
if ($@) {
$dbh->rollback;
die "Failed rebuilding user tables: $@\n";
}

say "Rebuilt users and user_identities tables.";
}

# Build username -> id map from the new (or dry-run simulated) data
my %username_to_id;
{
my $rows = $dbh->selectall_arrayref(
'SELECT id, username FROM users',
{ Slice => {} },
);
%username_to_id = map { $_->{username} => $_->{id} } @$rows;
}

unless ($opt->dry_run) {
$dbh->begin_work;
eval {
my $ins_id = $dbh->prepare(
'INSERT INTO user_identities (user_id, identity_name, identity_value) VALUES (?,?,?)'
);
for my $row (@identities) {
my $uid = $username_to_id{ $row->{username} };
unless ($uid) {
warn "No user id found for identity username '$row->{username}', skipping\n";
next;
}
$ins_id->execute($uid, $row->{identity_name}, $row->{identity_value});
}
$dbh->commit;
};
if ($@) {
$dbh->rollback;
die "Failed reinserting identities: $@\n";
}
say "Reinserted " . scalar(@identities) . " identity row(s).";
}

# ---------------------------------------------------------------------------
# 4. Migrate synergy_state JSON blobs
#
# These keys are known to be username-keyed hashes that need remapping:
# - preferences (all reactors with HasPreferences, plus _user_directory)
# - chatter (Status reactor)
# - doings (Status reactor)
# - user_last_report_times (TimeClock reactor)
# - user (Vestaboard reactor)
# ---------------------------------------------------------------------------

my @state_rows = @{ $dbh->selectall_arrayref(
'SELECT reactor_name, json FROM synergy_state',
{ Slice => {} },
) };

my $json = JSON::MaybeXS->new->utf8->canonical;

my %USERNAME_KEYED_KEYS = map { $_ => 1 } qw(
preferences
chatter
doings
user_last_report_times
user
);

for my $row (@state_rows) {
my $name = $row->{reactor_name};
my $state = eval { $json->decode($row->{json}) };
unless ($state && ref $state eq 'HASH') {
warn "Couldn't decode JSON for $name, skipping\n";
next;
}

my $changed = 0;
for my $key (keys %USERNAME_KEYED_KEYS) {
next unless exists $state->{$key} && ref $state->{$key} eq 'HASH';

my $orig = $state->{$key};
my %remapped;
for my $username (keys %$orig) {
# Skip if already looks like an integer id
if ($username =~ /\A[0-9]+\z/) {
$remapped{$username} = $orig->{$username};
next;
}
my $uid = $username_to_id{$username};
unless ($uid) {
warn "[$name/$key] No id found for username '$username', skipping\n";
next;
}
$remapped{$uid} = $orig->{$username};
$changed = 1;
}
$state->{$key} = \%remapped;
}

if ($changed) {
if ($opt->dry_run) {
say "[dry-run] Would remap $name state keys: " .
join(', ', grep { exists $state->{$_} } keys %USERNAME_KEYED_KEYS);
} else {
$dbh->do(
'UPDATE synergy_state SET json = ? WHERE reactor_name = ?',
undef,
$json->encode($state),
$name,
);
say "Updated state for $name.";
}
} else {
say "No changes needed for $name.";
}
}

$dbh->do('PRAGMA foreign_keys = ON');

say $opt->dry_run ? "Dry run complete — no changes made." : "Migration complete.";
17 changes: 10 additions & 7 deletions lib/Synergy/Environment.pm
Original file line number Diff line number Diff line change
Expand Up @@ -126,31 +126,34 @@ has state_dbh => (
);

sub _maybe_create_state_tables ($self) {
$self->state_dbh->do(q{
my $dbh = $self->state_dbh;

$dbh->do(q{
CREATE TABLE IF NOT EXISTS synergy_state (
reactor_name TEXT PRIMARY KEY,
stored_at INTEGER NOT NULL,
json TEXT NOT NULL
);
});

$self->state_dbh->do(q{
$dbh->do(q{
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
is_master INTEGER DEFAULT 0,
is_virtual INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0
);
});

$self->state_dbh->do(q{
$dbh->do(q{
CREATE TABLE IF NOT EXISTS user_identities (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
user_id INTEGER NOT NULL,
identity_name TEXT NOT NULL,
identity_value TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
CONSTRAINT constraint_username_identity UNIQUE (username, identity_name),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT constraint_user_identity UNIQUE (user_id, identity_name),
UNIQUE (identity_name, identity_value)
);
});
Expand Down
29 changes: 24 additions & 5 deletions lib/Synergy/Reactor/Status.pm
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,42 @@ listener chatter => async sub ($self, $event) {
return;
};

sub _remap_to_user_ids ($self, $hashref) {
my $ud = $self->hub->user_directory;
my %id_keyed;
for my $username (keys %$hashref) {
my $user = $ud->user_named($username) or next;
$id_keyed{ $user->id } = $hashref->{$username};
}
return \%id_keyed;
}

sub _remap_from_user_ids ($self, $hashref) {
my $ud = $self->hub->user_directory;
my %username_keyed;
for my $user_id (keys %$hashref) {
my $user = $ud->user_by_id($user_id) or next;
$username_keyed{ $user->username } = $hashref->{$user_id};
}
return \%username_keyed;
}

sub state ($self) {
return {
chatter => $self->_last_chatter,
doings => $self->_user_doings,
chatter => $self->_remap_to_user_ids($self->_last_chatter),
doings => $self->_remap_to_user_ids($self->_user_doings),
};
}

after register_with_hub => sub ($self, @) {
if (my $state = $self->fetch_state) {
if ($state->{chatter}) {
$self->_last_chatter->%* = $state->{chatter}->%*;
$self->_last_chatter->%* = $self->_remap_from_user_ids($state->{chatter})->%*;
}

if ($state->{doings}) {
my $doings = $self->_user_doings;

%$doings = $state->{doings}->%*;
%$doings = $self->_remap_from_user_ids($state->{doings})->%*;
}
}
};
Expand Down
18 changes: 15 additions & 3 deletions lib/Synergy/Reactor/TimeClock.pm
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,15 @@ has clock_out_channel => (
);

sub state ($self) {
my $ud = $self->hub->user_directory;
my %times_by_id;
for my $username (keys %{ $self->user_last_report_times }) {
my $user = $ud->user_named($username) or next;
$times_by_id{ $user->id } = $self->user_last_report_times->{$username};
}
return {
last_report_time => $self->last_report_time,
user_last_report_times => $self->user_last_report_times,
last_report_time => $self->last_report_time,
user_last_report_times => \%times_by_id,
};
}

Expand All @@ -127,7 +133,13 @@ after register_with_hub => sub ($self, @) {
}

if (my $times = $state->{user_last_report_times}) {
$self->_set_user_last_report_times($times);
my $ud = $self->hub->user_directory;
my %times_by_username;
for my $user_id (keys %$times) {
my $user = $ud->user_by_id($user_id) or next;
$times_by_username{ $user->username } = $times->{$user_id};
}
$self->_set_user_last_report_times(\%times_by_username);
}
}
};
Expand Down
Loading
Loading