mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-22 00:03:51 +02:00
505 lines
18 KiB
JavaScript
505 lines
18 KiB
JavaScript
import { dbVars } from '../database';
|
|
|
|
import sqliteService from '../sqlite.js';
|
|
|
|
const ACTIVITY_VIEW_KIND = {
|
|
ACTIVITY: 'activity',
|
|
OVERLAP: 'overlap'
|
|
};
|
|
|
|
const ACTIVITY_RANGE_CACHE_KIND = {
|
|
SESSIONS: 0,
|
|
TOP_WORLDS: 1
|
|
};
|
|
|
|
function syncStateTable() {
|
|
return `${dbVars.userPrefix}_activity_sync_state_v2`;
|
|
}
|
|
|
|
function sessionsTable() {
|
|
return `${dbVars.userPrefix}_activity_sessions_v2`;
|
|
}
|
|
|
|
function rangeCacheTable() {
|
|
return `${dbVars.userPrefix}_activity_range_cache_v2`;
|
|
}
|
|
|
|
function bucketCacheTable() {
|
|
return `${dbVars.userPrefix}_activity_bucket_cache_v2`;
|
|
}
|
|
|
|
function topWorldsCacheTable() {
|
|
return `${dbVars.userPrefix}_activity_top_worlds_cache_v2`;
|
|
}
|
|
|
|
function parseJson(value, fallback) {
|
|
if (!value) {
|
|
return fallback;
|
|
}
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Activity V2 is the formal, stable schema for the refactored Activity tab.
|
|
* Legacy activity_cache_* tables remain only for upgrade compatibility.
|
|
*/
|
|
const activityV2 = {
|
|
ACTIVITY_VIEW_KIND,
|
|
ACTIVITY_RANGE_CACHE_KIND,
|
|
|
|
async getActivitySourceSliceV2({ userId, isSelf, fromDays, toDays = 0 }) {
|
|
const fromDateIso = new Date(Date.now() - fromDays * 86400000).toISOString();
|
|
const toDateIso = toDays > 0
|
|
? new Date(Date.now() - toDays * 86400000).toISOString()
|
|
: '';
|
|
return isSelf
|
|
? this.getCurrentUserLocationSliceV2(fromDateIso, toDateIso)
|
|
: this.getFriendPresenceSliceV2(userId, fromDateIso, toDateIso);
|
|
},
|
|
|
|
async getActivitySourceAfterV2({ userId, isSelf, afterCreatedAt, inclusive = false }) {
|
|
return isSelf
|
|
? this.getCurrentUserLocationAfterV2(afterCreatedAt, inclusive)
|
|
: this.getFriendPresenceAfterV2(userId, afterCreatedAt);
|
|
},
|
|
|
|
async getFriendPresenceSliceV2(userId, fromDateIso, toDateIso = '') {
|
|
const rows = [];
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
rows.push({ created_at: dbRow[0], type: dbRow[1] });
|
|
},
|
|
`
|
|
SELECT created_at, type
|
|
FROM (
|
|
SELECT created_at, type, 0 AS sort_group
|
|
FROM (
|
|
SELECT created_at, type
|
|
FROM ${dbVars.userPrefix}_feed_online_offline
|
|
WHERE user_id = @userId
|
|
AND (type = 'Online' OR type = 'Offline')
|
|
AND created_at < @fromDateIso
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
)
|
|
UNION ALL
|
|
SELECT created_at, type, 1 AS sort_group
|
|
FROM ${dbVars.userPrefix}_feed_online_offline
|
|
WHERE user_id = @userId
|
|
AND (type = 'Online' OR type = 'Offline')
|
|
AND created_at >= @fromDateIso
|
|
${toDateIso ? 'AND created_at < @toDateIso' : ''}
|
|
)
|
|
ORDER BY created_at ASC, sort_group ASC
|
|
`,
|
|
{
|
|
'@userId': userId,
|
|
'@fromDateIso': fromDateIso,
|
|
'@toDateIso': toDateIso
|
|
}
|
|
);
|
|
|
|
if (toDateIso) {
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
rows.push({ created_at: dbRow[0], type: dbRow[1] });
|
|
},
|
|
`SELECT created_at, type
|
|
FROM ${dbVars.userPrefix}_feed_online_offline
|
|
WHERE user_id = @userId
|
|
AND (type = 'Online' OR type = 'Offline')
|
|
AND created_at >= @toDateIso
|
|
ORDER BY created_at ASC
|
|
LIMIT 1`,
|
|
{
|
|
'@userId': userId,
|
|
'@toDateIso': toDateIso
|
|
}
|
|
);
|
|
}
|
|
|
|
return rows.sort((left, right) => left.created_at.localeCompare(right.created_at));
|
|
},
|
|
|
|
async getFriendPresenceAfterV2(userId, afterCreatedAt) {
|
|
const rows = [];
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
rows.push({ created_at: dbRow[0], type: dbRow[1] });
|
|
},
|
|
`SELECT created_at, type
|
|
FROM ${dbVars.userPrefix}_feed_online_offline
|
|
WHERE user_id = @userId
|
|
AND (type = 'Online' OR type = 'Offline')
|
|
AND created_at > @afterCreatedAt
|
|
ORDER BY created_at`,
|
|
{
|
|
'@userId': userId,
|
|
'@afterCreatedAt': afterCreatedAt
|
|
}
|
|
);
|
|
return rows;
|
|
},
|
|
|
|
async getCurrentUserLocationSliceV2(fromDateIso, toDateIso = '') {
|
|
const rows = [];
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
rows.push({ created_at: dbRow[0], time: dbRow[1] || 0 });
|
|
},
|
|
`
|
|
SELECT created_at, time
|
|
FROM (
|
|
SELECT created_at, time, 0 AS sort_group
|
|
FROM (
|
|
SELECT created_at, time
|
|
FROM gamelog_location
|
|
WHERE created_at < @fromDateIso
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
)
|
|
UNION ALL
|
|
SELECT created_at, time, 1 AS sort_group
|
|
FROM gamelog_location
|
|
WHERE created_at >= @fromDateIso
|
|
${toDateIso ? 'AND created_at < @toDateIso' : ''}
|
|
${toDateIso
|
|
? `UNION ALL
|
|
SELECT created_at, time, 2 AS sort_group
|
|
FROM (
|
|
SELECT created_at, time
|
|
FROM gamelog_location
|
|
WHERE created_at >= @toDateIso
|
|
ORDER BY created_at
|
|
LIMIT 1
|
|
)`
|
|
: ''}
|
|
)
|
|
ORDER BY created_at ASC, sort_group ASC
|
|
`,
|
|
{
|
|
'@fromDateIso': fromDateIso,
|
|
'@toDateIso': toDateIso
|
|
}
|
|
);
|
|
return rows;
|
|
},
|
|
|
|
async getCurrentUserLocationAfterV2(afterCreatedAt, inclusive = false) {
|
|
const rows = [];
|
|
const operator = inclusive ? '>=' : '>';
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
rows.push({ created_at: dbRow[0], time: dbRow[1] || 0 });
|
|
},
|
|
`SELECT created_at, time
|
|
FROM gamelog_location
|
|
WHERE created_at ${operator} @afterCreatedAt
|
|
ORDER BY created_at`,
|
|
{ '@afterCreatedAt': afterCreatedAt }
|
|
);
|
|
return rows;
|
|
},
|
|
|
|
async getActivitySyncStateV2(userId) {
|
|
let row = null;
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
row = {
|
|
userId: dbRow[0],
|
|
updatedAt: dbRow[1] || '',
|
|
isSelf: Boolean(dbRow[2]),
|
|
sourceLastCreatedAt: dbRow[3] || '',
|
|
pendingSessionStartAt: typeof dbRow[4] === 'number' ? dbRow[4] : null,
|
|
cachedRangeDays: dbRow[5] || 0
|
|
};
|
|
},
|
|
`SELECT user_id, updated_at, is_self, source_last_created_at, pending_session_start_at, cached_range_days
|
|
FROM ${syncStateTable()}
|
|
WHERE user_id = @userId`,
|
|
{ '@userId': userId }
|
|
);
|
|
return row;
|
|
},
|
|
|
|
async upsertActivitySyncStateV2(entry) {
|
|
await sqliteService.executeNonQuery(
|
|
`INSERT OR REPLACE INTO ${syncStateTable()}
|
|
(user_id, updated_at, is_self, source_last_created_at, pending_session_start_at, cached_range_days)
|
|
VALUES (@userId, @updatedAt, @isSelf, @sourceLastCreatedAt, @pendingSessionStartAt, @cachedRangeDays)`,
|
|
{
|
|
'@userId': entry.userId,
|
|
'@updatedAt': entry.updatedAt || '',
|
|
'@isSelf': entry.isSelf ? 1 : 0,
|
|
'@sourceLastCreatedAt': entry.sourceLastCreatedAt || '',
|
|
'@pendingSessionStartAt': entry.pendingSessionStartAt,
|
|
'@cachedRangeDays': entry.cachedRangeDays || 0
|
|
}
|
|
);
|
|
},
|
|
|
|
async getActivitySessionsV2(userId) {
|
|
const sessions = [];
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
sessions.push({
|
|
start: dbRow[0],
|
|
end: dbRow[1],
|
|
isOpenTail: Boolean(dbRow[2]),
|
|
sourceRevision: dbRow[3] || ''
|
|
});
|
|
},
|
|
`SELECT start_at, end_at, is_open_tail, source_revision
|
|
FROM ${sessionsTable()}
|
|
WHERE user_id = @userId
|
|
ORDER BY start_at`,
|
|
{ '@userId': userId }
|
|
);
|
|
return sessions;
|
|
},
|
|
|
|
async replaceActivitySessionsV2(userId, sessions = []) {
|
|
await sqliteService.executeNonQuery('BEGIN');
|
|
try {
|
|
await sqliteService.executeNonQuery(
|
|
`DELETE FROM ${sessionsTable()} WHERE user_id = @userId`,
|
|
{ '@userId': userId }
|
|
);
|
|
await insertSessions(userId, sessions);
|
|
await sqliteService.executeNonQuery('COMMIT');
|
|
} catch (error) {
|
|
await sqliteService.executeNonQuery('ROLLBACK');
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async appendActivitySessionsV2({ userId, sessions = [], replaceFromStartAt = null }) {
|
|
await sqliteService.executeNonQuery('BEGIN');
|
|
try {
|
|
if (replaceFromStartAt !== null) {
|
|
await sqliteService.executeNonQuery(
|
|
`DELETE FROM ${sessionsTable()}
|
|
WHERE user_id = @userId AND start_at >= @replaceFromStartAt`,
|
|
{
|
|
'@userId': userId,
|
|
'@replaceFromStartAt': replaceFromStartAt
|
|
}
|
|
);
|
|
}
|
|
await insertSessions(userId, sessions);
|
|
await sqliteService.executeNonQuery('COMMIT');
|
|
} catch (error) {
|
|
await sqliteService.executeNonQuery('ROLLBACK');
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async getActivityRangeCacheV2(userId, rangeDays, cacheKind) {
|
|
let row = null;
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
row = {
|
|
userId: dbRow[0],
|
|
rangeDays: dbRow[1],
|
|
cacheKind: dbRow[2],
|
|
isComplete: Boolean(dbRow[3]),
|
|
builtFromCursor: dbRow[4] || '',
|
|
builtAt: dbRow[5] || ''
|
|
};
|
|
},
|
|
`SELECT user_id, range_days, cache_kind, is_complete, built_from_cursor, built_at
|
|
FROM ${rangeCacheTable()}
|
|
WHERE user_id = @userId AND range_days = @rangeDays AND cache_kind = @cacheKind`,
|
|
{
|
|
'@userId': userId,
|
|
'@rangeDays': rangeDays,
|
|
'@cacheKind': cacheKind
|
|
}
|
|
);
|
|
return row;
|
|
},
|
|
|
|
async upsertActivityRangeCacheV2(entry) {
|
|
await sqliteService.executeNonQuery(
|
|
`INSERT OR REPLACE INTO ${rangeCacheTable()}
|
|
(user_id, range_days, cache_kind, is_complete, built_from_cursor, built_at)
|
|
VALUES (@userId, @rangeDays, @cacheKind, @isComplete, @builtFromCursor, @builtAt)`,
|
|
{
|
|
'@userId': entry.userId,
|
|
'@rangeDays': entry.rangeDays,
|
|
'@cacheKind': entry.cacheKind,
|
|
'@isComplete': entry.isComplete ? 1 : 0,
|
|
'@builtFromCursor': entry.builtFromCursor || '',
|
|
'@builtAt': entry.builtAt || ''
|
|
}
|
|
);
|
|
},
|
|
|
|
async getActivityBucketCacheV2({
|
|
ownerUserId,
|
|
targetUserId = '',
|
|
rangeDays,
|
|
viewKind,
|
|
excludeKey = ''
|
|
}) {
|
|
let row = null;
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
row = {
|
|
ownerUserId: dbRow[0],
|
|
targetUserId: dbRow[1],
|
|
rangeDays: dbRow[2],
|
|
viewKind: dbRow[3],
|
|
excludeKey: dbRow[4] || '',
|
|
bucketVersion: dbRow[5] || 1,
|
|
builtFromCursor: dbRow[6] || '',
|
|
rawBuckets: parseJson(dbRow[7], []),
|
|
normalizedBuckets: parseJson(dbRow[8], []),
|
|
summary: parseJson(dbRow[9], {}),
|
|
builtAt: dbRow[10] || ''
|
|
};
|
|
},
|
|
`SELECT user_id, target_user_id, range_days, view_kind, exclude_key, bucket_version, built_from_cursor, raw_buckets_json, normalized_buckets_json, summary_json, built_at
|
|
FROM ${bucketCacheTable()}
|
|
WHERE user_id = @ownerUserId AND target_user_id = @targetUserId AND range_days = @rangeDays AND view_kind = @viewKind AND exclude_key = @excludeKey`,
|
|
{
|
|
'@ownerUserId': ownerUserId,
|
|
'@targetUserId': targetUserId,
|
|
'@rangeDays': rangeDays,
|
|
'@viewKind': viewKind,
|
|
'@excludeKey': excludeKey
|
|
}
|
|
);
|
|
return row;
|
|
},
|
|
|
|
async upsertActivityBucketCacheV2(entry) {
|
|
await sqliteService.executeNonQuery(
|
|
`INSERT OR REPLACE INTO ${bucketCacheTable()}
|
|
(user_id, target_user_id, range_days, view_kind, exclude_key, bucket_version, built_from_cursor, raw_buckets_json, normalized_buckets_json, summary_json, built_at)
|
|
VALUES (@ownerUserId, @targetUserId, @rangeDays, @viewKind, @excludeKey, @bucketVersion, @builtFromCursor, @rawBucketsJson, @normalizedBucketsJson, @summaryJson, @builtAt)`,
|
|
{
|
|
'@ownerUserId': entry.ownerUserId,
|
|
'@targetUserId': entry.targetUserId || '',
|
|
'@rangeDays': entry.rangeDays,
|
|
'@viewKind': entry.viewKind,
|
|
'@excludeKey': entry.excludeKey || '',
|
|
'@bucketVersion': entry.bucketVersion || 1,
|
|
'@builtFromCursor': entry.builtFromCursor || '',
|
|
'@rawBucketsJson': JSON.stringify(entry.rawBuckets || []),
|
|
'@normalizedBucketsJson': JSON.stringify(entry.normalizedBuckets || []),
|
|
'@summaryJson': JSON.stringify(entry.summary || {}),
|
|
'@builtAt': entry.builtAt || ''
|
|
}
|
|
);
|
|
},
|
|
|
|
async getActivityTopWorldsCacheV2(userId, rangeDays) {
|
|
const worlds = [];
|
|
let builtFromCursor = '';
|
|
let builtAt = '';
|
|
await sqliteService.execute(
|
|
(dbRow) => {
|
|
builtFromCursor = dbRow[0] || builtFromCursor;
|
|
builtAt = dbRow[1] || builtAt;
|
|
worlds.push({
|
|
worldId: dbRow[3],
|
|
worldName: dbRow[4],
|
|
visitCount: dbRow[5] || 0,
|
|
totalTime: dbRow[6] || 0
|
|
});
|
|
},
|
|
`SELECT built_from_cursor, built_at, rank_index, world_id, world_name, visit_count, total_time
|
|
FROM ${topWorldsCacheTable()}
|
|
WHERE user_id = @userId AND range_days = @rangeDays
|
|
ORDER BY rank_index`,
|
|
{
|
|
'@userId': userId,
|
|
'@rangeDays': rangeDays
|
|
}
|
|
);
|
|
if (worlds.length === 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
userId,
|
|
rangeDays,
|
|
builtFromCursor,
|
|
builtAt,
|
|
worlds
|
|
};
|
|
},
|
|
|
|
async replaceActivityTopWorldsCacheV2(entry) {
|
|
await sqliteService.executeNonQuery('BEGIN');
|
|
try {
|
|
await sqliteService.executeNonQuery(
|
|
`DELETE FROM ${topWorldsCacheTable()} WHERE user_id = @userId AND range_days = @rangeDays`,
|
|
{
|
|
'@userId': entry.userId,
|
|
'@rangeDays': entry.rangeDays
|
|
}
|
|
);
|
|
|
|
for (let index = 0; index < entry.worlds.length; index++) {
|
|
const world = entry.worlds[index];
|
|
await sqliteService.executeNonQuery(
|
|
`INSERT OR REPLACE INTO ${topWorldsCacheTable()}
|
|
(user_id, range_days, rank_index, world_id, world_name, visit_count, total_time, built_from_cursor, built_at)
|
|
VALUES (@userId, @rangeDays, @rankIndex, @worldId, @worldName, @visitCount, @totalTime, @builtFromCursor, @builtAt)`,
|
|
{
|
|
'@userId': entry.userId,
|
|
'@rangeDays': entry.rangeDays,
|
|
'@rankIndex': index,
|
|
'@worldId': world.worldId,
|
|
'@worldName': world.worldName || world.worldId,
|
|
'@visitCount': world.visitCount || 0,
|
|
'@totalTime': world.totalTime || 0,
|
|
'@builtFromCursor': entry.builtFromCursor || '',
|
|
'@builtAt': entry.builtAt || ''
|
|
}
|
|
);
|
|
}
|
|
|
|
await sqliteService.executeNonQuery('COMMIT');
|
|
} catch (error) {
|
|
await sqliteService.executeNonQuery('ROLLBACK');
|
|
throw error;
|
|
}
|
|
}
|
|
};
|
|
|
|
async function insertSessions(userId, sessions = []) {
|
|
if (sessions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const chunkSize = 250;
|
|
for (let chunkStart = 0; chunkStart < sessions.length; chunkStart += chunkSize) {
|
|
const chunk = sessions.slice(chunkStart, chunkStart + chunkSize);
|
|
const args = {};
|
|
const values = chunk.map((session, index) => {
|
|
const suffix = `${chunkStart + index}`;
|
|
args[`@userId_${suffix}`] = userId;
|
|
args[`@startAt_${suffix}`] = session.start;
|
|
args[`@endAt_${suffix}`] = session.end;
|
|
args[`@isOpenTail_${suffix}`] = session.isOpenTail ? 1 : 0;
|
|
args[`@sourceRevision_${suffix}`] = session.sourceRevision || '';
|
|
return `(@userId_${suffix}, @startAt_${suffix}, @endAt_${suffix}, @isOpenTail_${suffix}, @sourceRevision_${suffix})`;
|
|
});
|
|
|
|
await sqliteService.executeNonQuery(
|
|
`INSERT OR REPLACE INTO ${sessionsTable()}
|
|
(user_id, start_at, end_at, is_open_tail, source_revision)
|
|
VALUES ${values.join(', ')}`,
|
|
args
|
|
);
|
|
}
|
|
}
|
|
|
|
export { activityV2 };
|