Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
109af39
test: compatibility fixes with docker based test run with pre-started…
Feb 6, 2026
37efe7a
test: further test fixes
Feb 7, 2026
019b38d
chore: update plugin-interface to help release
Feb 7, 2026
0e67ed3
test: stability fixes and pg stat collection support
Feb 11, 2026
dae6331
perf(test): reduce deadlock stress test iterations and thread pool
Feb 11, 2026
274a707
perf(test): reduce DB connection pool test sleep from 65s to 8s
Feb 11, 2026
f919453
perf(test): replace DROP/CREATE DB with TRUNCATE-based cleanup
Feb 11, 2026
9d48e41
chore: re-generate implementation dependencies
Feb 17, 2026
fb88830
Merge remote-tracking branch 'origin/master' into test/external_pg
Feb 17, 2026
6ca3426
Merge remote-tracking branch 'origin/test/external_pg' into feat/isol…
porcellus Mar 27, 2026
b5b26dd
feat: add time_joined columns to app_id_to_user_id table
porcellus Mar 28, 2026
7ce48b2
feat: add ON UPDATE CASCADE to all FKs referencing app_id_to_user_id
porcellus Mar 28, 2026
b293684
feat: migrate getPrimaryUserIdUsingEmail to use recipe_user_tenants t…
porcellus Mar 28, 2026
ed6b19c
feat: migrate passwordless read queries to use reservation tables
porcellus Mar 28, 2026
2ea4bd9
fix: kill remaining SuperTokens processes in afterTesting to prevent …
porcellus Mar 28, 2026
ca7f6e1
feat: migrate ThirdParty reads to recipe_user_tenants (DEPRECATE-06)
porcellus Mar 28, 2026
7f20e8b
feat: migrate WebAuthn reads to reservation tables (DEPRECATE-07)
porcellus Mar 28, 2026
68953c2
feat: migrate getUsers() dashboard search to unified reservation tabl…
porcellus Mar 29, 2026
e636330
feat: migrate listUsersByAccountInfo to reservation tables
porcellus Mar 29, 2026
c09785e
feat: migrate active users, count, and account linking queries off al…
porcellus Mar 29, 2026
0742635
feat: migrate getPrimaryUserInfo, getTenantIds, and session queries o…
porcellus Mar 29, 2026
c3da1d4
feat: migrate tenant-scoped doesUserIdExist to reservation tables
porcellus Mar 29, 2026
65849b3
feat: fix Primery typo and optimize lockUsers to single query
porcellus Mar 29, 2026
7a34bdf
fix: Phase 1 migration bugs in PostgreSQL queries
porcellus Mar 30, 2026
14f6f29
test: add comprehensive reservation table integrity checker and tests
porcellus Mar 30, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ local.properties
addDevTag
addReleaseTag
.vscode
*.iml
*.iml
pg_stat_monitor_output
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ private synchronized void initialiseHikariDataSource() throws SQLException, Stor
attributes = "?" + attributes;
}

config.setJdbcUrl("jdbc:" + scheme + "://" + hostName + port + "/" + databaseName + attributes);
String jdbcUrl = "jdbc:" + scheme + "://" + hostName + port + "/" + databaseName + attributes;
config.setJdbcUrl(jdbcUrl);

if (userConfig.getUser() != null) {
config.setUsername(userConfig.getUser());
Expand Down
93 changes: 92 additions & 1 deletion src/main/java/io/supertokens/storage/postgresql/Start.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
import java.lang.reflect.Field;
import java.sql.BatchUpdateException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.SQLTransactionRollbackException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -1106,9 +1108,98 @@ public void addInfoToNonAuthRecipesBasedOnUserId(TenantIdentifier tenantIdentifi
}
}

// Track which auxiliary databases have already been DROP'd + CREATE'd in this JVM.
// The first call per DB name does a full DROP + CREATE for clean state between tests.
// Subsequent calls for the same DB name skip the DROP/CREATE entirely — avoiding the ~5s
// block that occurs when DROP DATABASE hits a database with active HikariCP connections.
private static final java.util.Set<String> ensuredDatabases = java.util.concurrent.ConcurrentHashMap.newKeySet();

@Override
public void modifyConfigToAddANewUserPoolForTesting(JsonObject config, int poolNumber) {
config.add("postgresql_database_name", new JsonPrimitive("st" + poolNumber));
// Use worker-specific database names to avoid conflicts during parallel test execution
String workerId = System.getProperty("org.gradle.test.worker", "");
String dbName = workerId.isEmpty() ? "st" + poolNumber : "st" + poolNumber + "_w" + workerId;

// Only auto-create databases for standard pool numbers (0-50).
// Higher pool numbers (like 1000) are used in tests that expect the database
// NOT to exist (for testing error handling).
if (poolNumber >= 0 && poolNumber <= 50) {
if (ensuredDatabases.add(dbName)) {
// First time seeing this DB in this JVM — do the full DROP + CREATE
ensureTestDatabaseExists(dbName, config);
}
// Otherwise, the DB was already created earlier in this JVM — skip
}

config.add("postgresql_database_name", new JsonPrimitive(dbName));
}

/**
* Helper method to get configuration values from environment variables or system properties.
*/
private static String getConfigValue(String name, String defaultValue) {
String value = System.getenv(name);
if (value == null || value.isEmpty()) {
value = System.getProperty(name);
}
return (value != null && !value.isEmpty()) ? value : defaultValue;
}

/**
* Ensures a test database exists, creating it if necessary.
* This is called during test setup to create auxiliary databases for multitenancy tests.
*/
private void ensureTestDatabaseExists(String dbName, JsonObject config) {
// Get connection info from config or use defaults
// Check both environment variables and system properties, matching DatabaseTestHelper behavior
String host = config.has("postgresql_host")
? config.get("postgresql_host").getAsString()
: getConfigValue("TEST_PG_HOST", "localhost");
String port = config.has("postgresql_port")
? String.valueOf(config.get("postgresql_port").getAsInt())
: getConfigValue("TEST_PG_PORT", getConfigValue("ST_POSTGRESQL_PLUGIN_SERVER_PORT", "5432"));
String user = config.has("postgresql_user")
? config.get("postgresql_user").getAsString()
: getConfigValue("TEST_PG_USER", "root");
String password = config.has("postgresql_password")
? config.get("postgresql_password").getAsString()
: getConfigValue("TEST_PG_PASSWORD", "root");

String adminUrl = "jdbc:postgresql://" + host + ":" + port + "/postgres";

try {
// Ensure driver is loaded
Class.forName("org.postgresql.Driver");
} catch (ClassNotFoundException e) {
// Driver should already be available
return;
}

try (Connection conn = DriverManager.getConnection(adminUrl, user, password);
Statement stmt = conn.createStatement()) {

// Terminate any lingering connections from previous test runs, then drop for clean state.
try {
stmt.executeUpdate(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '" + dbName + "' AND pid <> pg_backend_pid()");
} catch (SQLException ignored) {
// pg_stat_activity query might fail on some setups
}

try {
stmt.executeUpdate("DROP DATABASE IF EXISTS " + dbName);
} catch (SQLException ignored) {
// Ignore errors - database might still have connections
}

// Create fresh database
stmt.executeUpdate("CREATE DATABASE " + dbName);

} catch (SQLException e) {
// Database might already exist or creation failed - log but don't fail
// The actual connection attempt will surface any real issues
System.err.println("[Start] Warning: Could not create test database " + dbName + ": " + e.getMessage());
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ public class PostgreSQLConfig {
defaultValue = "null", isOptional = true, isEditable = true)
private Integer postgresql_minimum_idle_connections = null;


@IgnoreForAnnotationCheck
boolean isValidAndNormalised = false;

Expand Down Expand Up @@ -402,6 +403,7 @@ public Integer getMinimumIdleConnections() {
return postgresql_minimum_idle_connections;
}


public String getThirdPartyUserToTenantTable() {
return addSchemaAndPrefixToTableName("thirdparty_user_to_tenant");
}
Expand Down Expand Up @@ -685,7 +687,7 @@ private void validateAndNormalise(boolean skipValidation) throws InvalidConfigEx

{ // postgresql_host
if (postgresql_host == null) {
postgresql_host = "localhost";
postgresql_host = System.getProperty("ST_POSTGRESQL_PLUGIN_SERVER_HOST", "localhost");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ public static CanBecomePrimaryResult checkIfLoginMethodCanBecomePrimary(Start st

if (primaryUserId[0] != null) {
if (primaryUserId[0].equals(recipeUserId)) {
return CanBecomePrimaryResult.wasAlreadyAPrimeryUserResult();
return CanBecomePrimaryResult.wasAlreadyAPrimaryUserResult();
} else {
return CanBecomePrimaryResult.linkedWithAnotherPrimaryUserResult(primaryUserId[0]);
}
Expand Down Expand Up @@ -1346,6 +1346,151 @@ public static void reservePrimaryUserAccountInfos_Transaction(Start start, Trans

executeBatch(sqlCon, QUERY, primaryUserTenantSetters);
}

// ── Lookup queries (migrated from per-recipe queries) ──

/**
* Find all primary_or_recipe_user_ids that have a matching email in the given tenant.
* Replaces 4 separate per-recipe queries (emailpassword, passwordless, thirdparty, webauthn).
*/
public static List<String> listPrimaryUserIdsByEmail(Start start, TenantIdentifier tenantIdentifier,
String email)
throws SQLException, StorageQueryException {
String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id"
+ " FROM " + getConfig(start).getRecipeUserTenantsTable() + " rut"
+ " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid"
+ " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id"
+ " WHERE rut.app_id = ? AND rut.tenant_id = ?"
+ " AND rut.account_info_type = ? AND rut.account_info_value = ?";

return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, ACCOUNT_INFO_TYPE.EMAIL.toString());
pst.setString(4, email);
}, result -> {
List<String> userIds = new ArrayList<>();
while (result.next()) {
userIds.add(result.getString("primary_or_recipe_user_id"));
}
return userIds;
});
}

/**
* Find all primary_or_recipe_user_ids that have a matching phone number in the given tenant.
* Replaces PasswordlessQueries.getPrimaryUserByPhoneNumber().
*/
public static List<String> listPrimaryUserIdsByPhoneNumber(Start start, TenantIdentifier tenantIdentifier,
String phoneNumber)
throws SQLException, StorageQueryException {
String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id"
+ " FROM " + getConfig(start).getRecipeUserTenantsTable() + " rut"
+ " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid"
+ " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id"
+ " WHERE rut.app_id = ? AND rut.tenant_id = ?"
+ " AND rut.account_info_type = ? AND rut.account_info_value = ?";

return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString());
pst.setString(4, phoneNumber);
}, result -> {
List<String> userIds = new ArrayList<>();
while (result.next()) {
userIds.add(result.getString("primary_or_recipe_user_id"));
}
return userIds;
});
}

/**
* Find the primary_or_recipe_user_id for a thirdparty user by provider info in a tenant.
* Replaces ThirdPartyQueries.getUserIdByThirdPartyInfo().
*/
public static String getPrimaryUserIdByThirdPartyInfo(Start start, TenantIdentifier tenantIdentifier,
String thirdPartyId, String thirdPartyUserId)
throws SQLException, StorageQueryException {
String accountInfoValue = thirdPartyId + "::" + thirdPartyUserId;
String QUERY = "SELECT auid.primary_or_recipe_user_id"
+ " FROM " + getConfig(start).getRecipeUserTenantsTable() + " rut"
+ " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid"
+ " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id"
+ " WHERE rut.app_id = ? AND rut.tenant_id = ?"
+ " AND rut.account_info_type = ? AND rut.account_info_value = ?"
+ " LIMIT 1";

return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, ACCOUNT_INFO_TYPE.THIRD_PARTY.toString());
pst.setString(4, accountInfoValue);
}, result -> {
if (result.next()) {
return result.getString("primary_or_recipe_user_id");
}
return null;
});
}

/**
* Find all primary_or_recipe_user_ids for a thirdparty provider info across all tenants in an app.
* Replaces ThirdPartyQueries.listUserIdsByThirdPartyInfo().
* Uses recipe_user_account_infos (app-scoped) instead of recipe_user_tenants (tenant-scoped).
*/
public static List<String> listPrimaryUserIdsByThirdPartyInfo(Start start, AppIdentifier appIdentifier,
String thirdPartyId, String thirdPartyUserId)
throws SQLException, StorageQueryException {
String accountInfoValue = thirdPartyId + "::" + thirdPartyUserId;
String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id"
+ " FROM " + getConfig(start).getRecipeUserAccountInfosTable() + " ruai"
+ " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid"
+ " ON ruai.app_id = auid.app_id AND ruai.recipe_user_id = auid.user_id"
+ " WHERE ruai.app_id = ?"
+ " AND ruai.account_info_type = ? AND ruai.account_info_value = ?";

return execute(start, QUERY, pst -> {
pst.setString(1, appIdentifier.getAppId());
pst.setString(2, ACCOUNT_INFO_TYPE.THIRD_PARTY.toString());
pst.setString(3, accountInfoValue);
}, result -> {
List<String> userIds = new ArrayList<>();
while (result.next()) {
userIds.add(result.getString("primary_or_recipe_user_id"));
}
return userIds;
});
}

/**
* Transaction variant of listPrimaryUserIdsByThirdPartyInfo.
*/
public static List<String> listPrimaryUserIdsByThirdPartyInfo_Transaction(Start start, Connection sqlCon,
AppIdentifier appIdentifier,
String thirdPartyId,
String thirdPartyUserId)
throws SQLException, StorageQueryException {
String accountInfoValue = thirdPartyId + "::" + thirdPartyUserId;
String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id"
+ " FROM " + getConfig(start).getRecipeUserAccountInfosTable() + " ruai"
+ " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid"
+ " ON ruai.app_id = auid.app_id AND ruai.recipe_user_id = auid.user_id"
+ " WHERE ruai.app_id = ?"
+ " AND ruai.account_info_type = ? AND ruai.account_info_value = ?";

return execute(sqlCon, QUERY, pst -> {
pst.setString(1, appIdentifier.getAppId());
pst.setString(2, ACCOUNT_INFO_TYPE.THIRD_PARTY.toString());
pst.setString(3, accountInfoValue);
}, result -> {
List<String> userIds = new ArrayList<>();
while (result.next()) {
userIds.add(result.getString("primary_or_recipe_user_id"));
}
return userIds;
});
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ static String getQueryToCreateUsersTable(Start start) {
+ "CONSTRAINT " + Utils.getConstraintName(schema, emailPasswordUsersTable, "user_id", "fkey")
+ " FOREIGN KEY(app_id, user_id)"
+ " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() +
" (app_id, user_id) ON DELETE CASCADE,"
" (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE,"
+ "CONSTRAINT " + Utils.getConstraintName(schema, emailPasswordUsersTable, null, "pkey")
+ " PRIMARY KEY (app_id, user_id)"
+ ");";
Expand Down Expand Up @@ -291,12 +291,15 @@ public static AuthRecipeUserInfo signUp(Start start, TenantIdentifier tenantIden
try {
{ // app_id_to_user_id
String QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable()
+ "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)";
+ "(app_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, primary_or_recipe_user_time_joined)"
+ " VALUES(?, ?, ?, ?, ?, ?)";
update(sqlCon, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, userId);
pst.setString(3, userId);
pst.setString(4, EMAIL_PASSWORD.toString());
pst.setLong(5, timeJoined);
pst.setLong(6, timeJoined);
});
}

Expand Down Expand Up @@ -362,7 +365,8 @@ public static void importUsers_Transaction(Start start, Connection sqlCon, List<
throws StorageQueryException, StorageTransactionLogicException {
try {
String app_id_to_user_id_QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable()
+ "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)" + " VALUES(?, ?, ?, ?, ?)";
+ "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, primary_or_recipe_user_time_joined)"
+ " VALUES(?, ?, ?, ?, ?, ?, ?)";

String all_auth_recipe_users_QUERY = "INSERT INTO " + getConfig(start).getUsersTable() +
"(app_id, tenant_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, " +
Expand Down Expand Up @@ -403,6 +407,8 @@ public static void importUsers_Transaction(Start start, Connection sqlCon, List<
pst.setString(3, primaryOrRecipeUserId);
pst.setBoolean(4, isLinkedOrIsPrimaryUser);
pst.setString(5, EMAIL_PASSWORD.toString());
pst.setLong(6, user.timeJoinedMSSinceEpoch);
pst.setLong(7, user.timeJoinedMSSinceEpoch);
});

emailPasswordUsersSetters.add(pst -> {
Expand Down Expand Up @@ -576,11 +582,12 @@ public static List<LoginMethod> getUsersInfoUsingIdList_Transaction(Start start,
public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier tenantIdentifier,
String email)
throws StorageQueryException, SQLException {
String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id "
+ "FROM " + getConfig(start).getEmailPasswordUserToTenantTable() + " AS ep" +
" JOIN " + getConfig(start).getUsersTable() + " AS all_users" +
" ON ep.app_id = all_users.app_id AND ep.user_id = all_users.user_id" +
" WHERE ep.app_id = ? AND ep.tenant_id = ? AND ep.email = ?";
String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id AS user_id "
+ "FROM " + getConfig(start).getRecipeUserTenantsTable() + " AS rut"
+ " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS auid"
+ " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id"
+ " WHERE rut.app_id = ? AND rut.tenant_id = ? AND rut.account_info_type = 'email'"
+ " AND rut.account_info_value = ? AND rut.recipe_id = 'emailpassword'";

return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
Expand Down
Loading
Loading