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
24 changes: 12 additions & 12 deletions luau/twshell/common/db.luau
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,39 @@
--- A wrapper around a sqlx PgPool that provides methods to execute queries and fetch results, with values mapped through the DbValueMapper
export type Db = {
--- Returns if we support the given type for casting
supports: (type: string) -> boolean,
supports: (self: Db, type: string) -> boolean,
--- Casts a Lua value into an OpaqueValue of the specified type.
cast: (value: any, type: string) -> DbValue,
cast: (self: Db, value: any, type: string) -> DbValue,
--- Executes a query and returns number of affected rows
execute: (sql: string, params: {DbValue}) -> number,
execute: (self: Db, sql: string, params: {DbValue}) -> number,
--- Executes a query and returns all results
fetchall: (sql: string, params: {DbValue}) -> {PgRow},
fetchall: (self: Db, sql: string, params: {DbValue}) -> {PgRow},
--- Begins a transaction and returns a DbTx object that can be used to execute queries within the transaction
begin: () -> DbTx,
begin: (self: Db) -> DbTx,
}

--- A wrapper around a sqlx Transaction that provides methods to execute queries and fetch results, with values mapped through the DbValueMapper
export type DbTx = {
--- Executes a query and returns number of affected rows
execute: (sql: string, params: {DbValue}) -> number,
execute: (self: DbTx, sql: string, params: {DbValue}) -> number,
--- Executes a query and returns all results
fetchall: (sql: string, params: {DbValue}) -> {PgRow},
fetchall: (self: DbTx, sql: string, params: {DbValue}) -> {PgRow},
--- Commits the transaction
commit: () -> (),
commit: (self: DbTx) -> (),
--- Rolls back the transaction
rollback: () -> (),
rollback: (self: DbTx) -> (),
}

export type PgRow = {
--- Gets the value of a column by index (1-based) and type. Does not immediately convert the value to a Lua type, but instead returns a Value object that can be converted on demand.
get: (index: number, type: string) -> DbValue,
get: (self: PgRow, index: number, type: string) -> DbValue,
}

export type DbValue = {
--- Gets the underlying type
type: () -> string,
type: (self: DbValue) -> string,
--- Takes out the underlying value
take: () -> any,
take: (self: DbValue) -> any,
}

export type Id = {
Expand Down
154 changes: 154 additions & 0 deletions luau/twshell/migrations/stings.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
local datetime = require"@antiraid/datetime"
local typesext = require"@antiraid/typesext"
local db = require"../common/db"

local OLD_SCOPE = "builtins.stings"
local KEX_SCOPE = "__kexsbuiltins.stings"
local FAR_FUTURE_SECS = 100 * 365 * 24 * 60 * 60

return {
up = function(db: db.Db)
print("[stings] merging old sting data into kex records (pre-kv_scope_unnest)")

local tx = db:begin()

local ok, err = pcall(function()
local sting_rows = tx:fetchall(
"SELECT owner_id, owner_type, key, scopes, value FROM tenant_kv WHERE $1 = ANY(scopes)",
{ db:cast(OLD_SCOPE, "string") }
)

type StingFields = { owner_id: string, owner_type: string, key: string, userid: string, modid: string?, reason: string, stings: number }
local old_stings: {[string]: StingFields} = {}
local missing_userid = 0
for _, row in sting_rows do
local owner_id = row:get(0, "string"):take() :: string
local owner_type = row:get(1, "string"):take() :: string
local key = row:get(2, "string"):take() :: string
local scopes = row:get(3, "{string}"):take() :: {string}
local value = row:get(4, "custom@khronosvalue"):take() :: any
Comment on lines +25 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify API contract and call-site mismatch (read-only).
rg -n 'get:\s*\(self: PgRow, index: number' luau/twshell/common/db.luau
rg -n 'row:get\([0-9]+' luau/twshell/migrations/stings.luau

Repository: Anti-Raid/template-worker

Length of output: 880


🏁 Script executed:

# Read context around the PgRow.get function definition in db.luau
cat -n luau/twshell/common/db.luau | sed -n '25,45p'

Repository: Anti-Raid/template-worker

Length of output: 852


🏁 Script executed:

# Search for all usages of PgRow.get across the codebase to understand indexing patterns
rg -A 2 -B 2 'row:get\(' luau/twshell/ --type lua

Repository: Anti-Raid/template-worker

Length of output: 51


PgRow.get indexes are off by one (0-based used, API is 1-based).

Lines 25-29 and 68-72 use row:get(0, ...) through row:get(4, ...), but PgRow.get is documented as 1-based. This will read from wrong columns during migration.

🔧 Proposed fix
-                local owner_id = row:get(0, "string"):take() :: string
-                local owner_type = row:get(1, "string"):take() :: string
-                local key = row:get(2, "string"):take() :: string
-                local scopes = row:get(3, "{string}"):take() :: {string}
-                local value = row:get(4, "custom@khronosvalue"):take() :: any
+                local owner_id = row:get(1, "string"):take() :: string
+                local owner_type = row:get(2, "string"):take() :: string
+                local key = row:get(3, "string"):take() :: string
+                local scopes = row:get(4, "{string}"):take() :: {string}
+                local value = row:get(5, "custom@khronosvalue"):take() :: any
@@
-                local id = row:get(0, "string"):take() :: string
-                local owner_id = row:get(1, "string"):take() :: string
-                local owner_type = row:get(2, "string"):take() :: string
-                local key = row:get(3, "string"):take() :: string
-                local value = row:get(4, "custom@khronosvalue"):take() :: any
+                local id = row:get(1, "string"):take() :: string
+                local owner_id = row:get(2, "string"):take() :: string
+                local owner_type = row:get(3, "string"):take() :: string
+                local key = row:get(4, "string"):take() :: string
+                local value = row:get(5, "custom@khronosvalue"):take() :: any
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
local owner_id = row:get(0, "string"):take() :: string
local owner_type = row:get(1, "string"):take() :: string
local key = row:get(2, "string"):take() :: string
local scopes = row:get(3, "{string}"):take() :: {string}
local value = row:get(4, "custom@khronosvalue"):take() :: any
local owner_id = row:get(1, "string"):take() :: string
local owner_type = row:get(2, "string"):take() :: string
local key = row:get(3, "string"):take() :: string
local scopes = row:get(4, "{string}"):take() :: {string}
local value = row:get(5, "custom@khronosvalue"):take() :: any
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@luau/twshell/migrations/stings.luau` around lines 25 - 29, The PgRow.get
calls are using 0-based indexes but the API is 1-based; update the row:get(...)
calls for the migration tuple unpacking (the locals owner_id, owner_type, key,
scopes, value) from 0..4 to 1..5 (and do the same for the second occurrence
later around the other unpack block at lines 68-72) so each call uses the
correct 1-based column index; search for any other row:get(...) in this
migration and adjust similarly to ensure all column accesses use 1-based
indices.


local userid: string? = nil
for _, s in scopes do
if s ~= OLD_SCOPE then
userid = s
break
end
end

if not userid then
print(string.format("[stings] sting key=%s owner=%s/%s has no userid in scopes %s; skipping", key, owner_type, owner_id, table.concat(scopes, ",")))
missing_userid += 1
continue
end
Comment on lines +39 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Skipped rows are still deleted, causing data loss.

Line 42 skips rows with missing userid, but Line 138 deletes all OLD_SCOPE rows anyway. That drops records you explicitly did not migrate.

🔧 Proposed fix
-            local deleted = tx:execute(
-                "DELETE FROM tenant_kv WHERE $1 = ANY(scopes)",
-                { db:cast(OLD_SCOPE, "string") }
-            )
+            local deleted = tx:execute(
+                [[DELETE FROM tenant_kv
+                  WHERE $1 = ANY(scopes)
+                  AND EXISTS (
+                      SELECT 1 FROM unnest(scopes) AS s WHERE s <> $1
+                  )]],
+                { db:cast(OLD_SCOPE, "string") }
+            )

Also applies to: 138-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@luau/twshell/migrations/stings.luau` around lines 39 - 43, The migration is
skipping rows when userid is missing (see the missing_userid increment and
continue) but later unconditionally deletes all rows in OLD_SCOPE (lines around
the OLD_SCOPE deletion), causing data loss; change the deletion logic so only
rows that were actually migrated are removed—e.g., accumulate migrated keys (or
mark processed rows) when handling key/owner_type/owner_id/scopes and then run
DELETE only for those keys (or add WHERE userid IS NOT NULL and match the
processed key set) instead of deleting all OLD_SCOPE rows; ensure references to
missing_userid, key, owner_type, owner_id, scopes, and OLD_SCOPE are used to
identify which rows to keep vs delete.


local composite = owner_id .. "|" .. owner_type .. "|" .. key
old_stings[composite] = {
owner_id = owner_id,
owner_type = owner_type,
key = key,
userid = userid,
modid = value.modId or value.modid,
reason = value.reason or "",
stings = value.stings or 1,
}
end
print(string.format("[stings] loaded %d old sting rows (%d skipped)", #sting_rows - missing_userid, missing_userid))

local kex_rows = tx:fetchall(
"SELECT id, owner_id, owner_type, key, value FROM tenant_kv WHERE $1 = ANY(scopes)",
{ db:cast(KEX_SCOPE, "string") }
)
print(string.format("[stings] found %d existing kex rows", #kex_rows))

local merged = 0
local matched: {[string]: boolean} = {}

for _, row in kex_rows do
local id = row:get(0, "string"):take() :: string
local owner_id = row:get(1, "string"):take() :: string
local owner_type = row:get(2, "string"):take() :: string
local key = row:get(3, "string"):take() :: string
local value = row:get(4, "custom@khronosvalue"):take() :: any

local composite = owner_id .. "|" .. owner_type .. "|" .. key
local sting = old_stings[composite]
if not sting then
print(string.format("[stings] kex key=%s owner=%s/%s has no matching sting data; leaving as-is", key, owner_type, owner_id))
continue
end
matched[composite] = true

local new_value = {
expiresat = value.expiresat,
data = {
userid = sting.userid,
modid = sting.modid,
reason = sting.reason,
stings = sting.stings,
},
}

tx:execute(
"UPDATE tenant_kv SET value = $1, last_updated_at = NOW() WHERE id = $2",
{
db:cast(new_value, "custom@khronosvalue"),
db:cast(id, "string"),
}
)
merged += 1
end
print(string.format("[stings] merged %d kex rows with matching sting data", merged))

local inserted = 0
local far_future = datetime.UTC:now() + datetime.timedelta_seconds(FAR_FUTURE_SECS)
for composite, sting in old_stings do
if matched[composite] then continue end

local new_id = typesext.randstring(64)
local new_value = {
expiresat = far_future,
data = {
userid = sting.userid,
modid = sting.modid,
reason = sting.reason,
stings = sting.stings,
},
}

tx:execute(
[[INSERT INTO tenant_kv (id, owner_id, owner_type, key, value, scopes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (owner_id, owner_type, key, scopes) DO UPDATE SET value = EXCLUDED.value, last_updated_at = NOW()]],
{
db:cast(new_id, "string"),
db:cast(sting.owner_id, "string"),
db:cast(sting.owner_type, "string"),
db:cast(sting.key, "string"),
db:cast(new_value, "custom@khronosvalue"),
db:cast({KEX_SCOPE}, "{string}"),
}
)
inserted += 1
end
if inserted > 0 then
print(string.format("[stings] inserted %d new kex rows (no prior expiry, assigned far-future)", inserted))
end

local deleted = tx:execute(
"DELETE FROM tenant_kv WHERE $1 = ANY(scopes)",
{ db:cast(OLD_SCOPE, "string") }
)
print(string.format("[stings] deleted %d rows from scope='%s'", deleted, OLD_SCOPE))
end)

if not ok then
print("[stings] migration failed: " .. tostring(err))
tx:rollback()
error(err)
end

tx:commit()
print("[stings] committed")
end,
}
28 changes: 15 additions & 13 deletions src/migrations/kv_scope_unnest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,39 @@ pub static MIGRATION: Migration = Migration {

let stmts = [
"ALTER TABLE tenant_kv ADD COLUMN scopes_2 TEXT;",
"ALTER TABLE tenant_kv DROP CONSTRAINT kv_unique_entry;"
"ALTER TABLE tenant_kv DROP CONSTRAINT kv_unique_entry;",
];

for stmt in stmts.iter() {
sqlx::query(stmt)
.execute(&mut *tx)
.await?;
sqlx::query(stmt).execute(&mut *tx).await?;
}

#[derive(sqlx::FromRow, Debug)]
pub struct TenantKv {
owner_id: String,
owner_type: String,
key: String,
scopes: Vec<String>
scopes: Vec<String>,
}

let rows = sqlx::query_as::<_, TenantKv>("SELECT owner_id, owner_type, key, scopes FROM tenant_kv")
let rows = sqlx::query_as::<_, TenantKv>(
"SELECT owner_id, owner_type, key, scopes FROM tenant_kv",
)
.fetch_all(&mut *tx)
.await?;

for row in rows {
if row.scopes.is_empty() {
panic!("No scopes found for {row:?}!");
}
if row.scopes.len() > 1 {
panic!("Multiple scopes found for {row:?}!");
}
let scope = row.scopes[0].clone();
let scope = row
.scopes
.iter()
.find(|elem| elem.chars().any(|c| !c.is_numeric()));

let Some(scope) = scope else {
panic!("No non-numeric scopes found for {row:?}!");
};

sqlx::query("UPDATE tenant_kv SET scopes_2 = $1 WHERE owner_id = $2 and owner_type = $3 and key = $4")
.bind(scope)
Expand All @@ -56,9 +60,7 @@ pub static MIGRATION: Migration = Migration {
];

for stmt in stmts.iter() {
sqlx::query(stmt)
.execute(&mut *tx)
.await?;
sqlx::query(stmt).execute(&mut *tx).await?;
}

tx.commit().await?;
Expand Down
24 changes: 12 additions & 12 deletions src/migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ use rust_embed::Embed;

use crate::{master::mainthread::{RunInThreadFn, run_in_thread}, worker::builtins::TemplatingTypes};

pub const RUST_MIGRATIONS: [Migration; 11] = [
// Do not change order of migrations without verifying dependencies
// Rust migrations that must run before the luau migrations in `luau/twshell/migrations/`.
// Do not change order without verifying dependencies.
pub const RUST_MIGRATIONS_BEFORE_LUAU: [Migration; 11] = [
khronosvalue_v2::MIGRATION,
kv_generic::MIGRATION,
tenantstate::MIGRATION,
Expand All @@ -37,8 +38,10 @@ pub const RUST_MIGRATIONS: [Migration; 11] = [
tenant_kv_add_bytea::MIGRATION,
];

pub const POST_LUAU_RUST_MIGRATIONS: [Migration; 1] = [
// Migrations that need to be applied after all Luau migrations have finished
// Rust migrations that must run after the luau migrations.
// kv_scope_unnest collapses scopes[] -> scope, which loses the userid that
// stings.luau needs to read from the per-row scopes array.
pub const RUST_MIGRATIONS_AFTER_LUAU: [Migration; 1] = [
kv_scope_unnest::MIGRATION,
];

Expand Down Expand Up @@ -119,19 +122,16 @@ enum MigrationType {

/// Computes the list of migrations to apply, including both hardcoded Rust migrations and Luau migrations embedded in the binary
fn migrations() -> Result<Vec<MigrationType>, crate::Error> {
let mut base_migrations = Vec::new();
let mut base_migrations = RUST_MIGRATIONS_BEFORE_LUAU.into_iter().map(MigrationType::Rust).collect::<Vec<_>>();

for migration in RUST_MIGRATIONS {
base_migrations.push(MigrationType::Rust(migration));
}

// Add luau migrations from MigrationsFolder after all base rust once have finished
// Add luau migrations from MigrationsFolder
for entry in MigrationsFolder::iter() {
base_migrations.push(MigrationType::Luau(entry));
}

for migration in POST_LUAU_RUST_MIGRATIONS {
base_migrations.push(MigrationType::Rust(migration));
// Rust migrations that depend on luau migrations having already run
for mig in RUST_MIGRATIONS_AFTER_LUAU.iter() {
base_migrations.push(MigrationType::Rust(*mig));
}

Ok(base_migrations)
Expand Down