diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 79b002145..86aedf5a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,12 +27,14 @@ jobs: node-version: ${{ matrix.node-version }} - name: Backend Tests (with coverage) + env: + NODE_OPTIONS: --max-old-space-size=8192 run: | rm package-lock.json npm install -g npm@latest npm install npm run build - npm run test:backend -- --coverage + npm run test:backend -- --coverage --maxWorkers=2 --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=lcov - name: Upload backend coverage report if: ${{ always() && hashFiles('coverage/**/coverage-summary.json') != '' }} diff --git a/src/backend/src/modules/apps/AppInformationService.js b/src/backend/src/modules/apps/AppInformationService.js index f4c105df9..8f0ac71ce 100644 --- a/src/backend/src/modules/apps/AppInformationService.js +++ b/src/backend/src/modules/apps/AppInformationService.js @@ -23,6 +23,8 @@ const { redisClient } = require('../../clients/redis/redisSingleton'); const { deleteRedisKeys } = require('../../clients/redis/deleteRedisKeys.js'); const { setRedisCacheValue } = require('../../clients/redis/cacheUpdate.js'); const { AppRedisCacheSpace } = require('./AppRedisCacheSpace.js'); +const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; +const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse'; /** * @class AppInformationService @@ -677,10 +679,11 @@ class AppInformationService extends BaseService { * * @param {string} app_uid - The unique identifier of the app to be deleted. * @param {Object} [app] - The app object, if already fetched. If not provided, it will be retrieved. + * @param {Object} [options] - Optional delete behavior flags. * @throws {Error} If the app is not found in either cache or database. * @returns {Promise} A promise that resolves when the app has been successfully deleted. */ - async delete_app (app_uid, app) { + async delete_app (app_uid, app, options = {}) { const db = this.services.get('database').get(DB_READ, 'apps'); if ( ! app ) { @@ -710,6 +713,10 @@ class AppInformationService extends BaseService { [app_uid], ); + if ( ! options.preserveCanonicalUidAlias ) { + await this.cleanupCanonicalAppUidAliases_(app_uid); + } + // remove from caches AppRedisCacheSpace.invalidateCachedApp(app, { includeStats: true, @@ -742,6 +749,59 @@ class AppInformationService extends BaseService { }); } + buildCanonicalAppUidAliasKey_ (appUid) { + return `${APP_UID_ALIAS_KEY_PREFIX}:${appUid}`; + } + + buildCanonicalAppUidAliasReverseKey_ (canonicalAppUid) { + return `${APP_UID_ALIAS_REVERSE_KEY_PREFIX}:${canonicalAppUid}`; + } + + normalizeCanonicalAliasUidList_ (value) { + if ( ! Array.isArray(value) ) return []; + const normalizedList = []; + const seen = new Set(); + for ( const item of value ) { + if ( typeof item !== 'string' || !item ) continue; + if ( seen.has(item) ) continue; + seen.add(item); + normalizedList.push(item); + } + return normalizedList; + } + + async cleanupCanonicalAppUidAliases_ (appUid) { + if ( typeof appUid !== 'string' || !appUid ) return; + + const kvStore = this.services.get('puter-kvstore'); + const suService = this.services.get('su'); + if ( !kvStore || typeof kvStore.get !== 'function' || typeof kvStore.del !== 'function' ) return; + if ( !suService || typeof suService.sudo !== 'function' ) return; + + const selfAliasKey = this.buildCanonicalAppUidAliasKey_(appUid); + const reverseKey = this.buildCanonicalAppUidAliasReverseKey_(appUid); + + try { + await suService.sudo(async () => { + const reverseValue = await kvStore.get({ key: reverseKey }); + const reverseAliases = this.normalizeCanonicalAliasUidList_(reverseValue); + + const deleteOps = [ + kvStore.del({ key: selfAliasKey }), + kvStore.del({ key: reverseKey }), + ]; + for ( const oldUid of reverseAliases ) { + deleteOps.push(kvStore.del({ + key: this.buildCanonicalAppUidAliasKey_(oldUid), + })); + } + await Promise.all(deleteOps); + }); + } catch { + // KV cleanup is best-effort. + } + } + // Helper function to generate array of all periods between start and end dates generateAllPeriods (startDate, endDate, grouping) { const periods = []; diff --git a/src/backend/src/modules/data-access/AppService.comp.test.js b/src/backend/src/modules/data-access/AppService.comp.test.js index 3a73e2ecf..b8b37c710 100644 --- a/src/backend/src/modules/data-access/AppService.comp.test.js +++ b/src/backend/src/modules/data-access/AppService.comp.test.js @@ -880,7 +880,7 @@ describe('AppService Regression Prevention Tests', () => { ); expect(joinedRows).toHaveLength(1); expect(joinedRows[0].uid).toBe(existingUid); - expect(joinedRows[0].name).toBe('joinable-update-existing'); + expect(joinedRows[0].name).toBe('joinable-update-merged'); expect(joinedRows[0].title).toBe('Joinable Update Merged'); expect(joinedRows[0].owner_user_id).toBe(user.id); @@ -949,7 +949,7 @@ describe('AppService Regression Prevention Tests', () => { [existingUid], ); expect(targetRows).toHaveLength(1); - expect(targetRows[0].name).toBe('existing-target-name'); + expect(targetRows[0].name).toBe('staging-app-center'); expect(targetRows[0].title).toBe('Merged Title'); const sourceRows = await db.read( diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 410ba1b83..ee0ac5c51 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -29,6 +29,8 @@ const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/; const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; +const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse'; +const APP_UID_ALIAS_TTL_SECONDS = 60 * 60 * 24 * 90; const indexUrlUniquenessExemptionCandidates = [ 'https://dev-center.puter.com/coming-soon', ]; @@ -1274,6 +1276,23 @@ export default class AppService extends BaseService { return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`; } + #buildCanonicalAppUidAliasReverseKey (canonicalAppUid) { + return `${APP_UID_ALIAS_REVERSE_KEY_PREFIX}:${canonicalAppUid}`; + } + + #normalizeCanonicalAliasUidList (value) { + if ( ! Array.isArray(value) ) return []; + const normalizedList = []; + const seen = new Set(); + for ( const item of value ) { + if ( typeof item !== 'string' || !item ) continue; + if ( seen.has(item) ) continue; + seen.add(item); + normalizedList.push(item); + } + return normalizedList; + } + async #readCanonicalAppUidAlias (oldAppUid) { if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null; @@ -1305,11 +1324,27 @@ export default class AppService extends BaseService { if ( !suService || typeof suService.sudo !== 'function' ) return; const key = this.#buildCanonicalAppUidAliasKey(oldAppUid); + const reverseKey = this.#buildCanonicalAppUidAliasReverseKey(canonicalAppUid); + const expireAt = Math.floor(Date.now() / 1000) + APP_UID_ALIAS_TTL_SECONDS; try { - await suService.sudo(() => kvStore.set({ - key, - value: canonicalAppUid, - })); + await suService.sudo(async () => { + const reverseValue = await kvStore.get({ key: reverseKey }); + const reverseAliases = this.#normalizeCanonicalAliasUidList(reverseValue); + if ( ! reverseAliases.includes(oldAppUid) ) { + reverseAliases.push(oldAppUid); + } + + await kvStore.set({ + key, + value: canonicalAppUid, + expireAt, + }); + await kvStore.set({ + key: reverseKey, + value: reverseAliases, + expireAt, + }); + }); } catch { // Alias writes are best-effort. } @@ -1390,11 +1425,20 @@ export default class AppService extends BaseService { ...object, uid: appToJoin.uid, }; + const requestedJoinedName = ( + typeof joinedObject.name === 'string' + ? joinedObject.name.trim() + : '' + ) || null; + const shouldReapplyRequestedNameAfterMerge = ( + !!object?.uid + && !!requestedJoinedName + ); if ( object?.uid && joinedObject.name !== undefined ) { delete joinedObject.name; } - const joinedApp = await this.#update({ + let joinedApp = await this.#update({ object: joinedObject, options, }); @@ -1406,10 +1450,22 @@ export default class AppService extends BaseService { }); const svc_appInformation = this.services.get('app-information'); if ( svc_appInformation?.delete_app ) { - await svc_appInformation.delete_app(sourceAppUid); + await svc_appInformation.delete_app(sourceAppUid, undefined, { + preserveCanonicalUidAlias: true, + }); } } + if ( shouldReapplyRequestedNameAfterMerge ) { + joinedApp = await this.#update({ + object: { + uid: appToJoin.uid, + name: requestedJoinedName, + }, + options, + }); + } + return joinedApp; } diff --git a/src/backend/src/modules/data-access/AppService.test.js b/src/backend/src/modules/data-access/AppService.test.js index 621ce22d8..5fa6525f0 100644 --- a/src/backend/src/modules/data-access/AppService.test.js +++ b/src/backend/src/modules/data-access/AppService.test.js @@ -1880,11 +1880,15 @@ describe('AppService', () => { expect.stringContaining('UPDATE apps SET'), expect.arrayContaining(['Joined Update Title', 777]), ); - expect(mockAppInformationService.delete_app).toHaveBeenCalledWith('app-uid-123'); - expect(mockKvStoreService.set).toHaveBeenCalledWith({ + expect(mockAppInformationService.delete_app).toHaveBeenCalledWith( + 'app-uid-123', + undefined, + { preserveCanonicalUidAlias: true }, + ); + expect(mockKvStoreService.set).toHaveBeenCalledWith(expect.objectContaining({ key: 'app:canonicalUidAlias:app-uid-123', value: 'app-conflict-uid', - }); + })); }); it('should throw when owned hosted index_url is already in use on update', async () => { diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index a92fcb613..679f5807e 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -31,6 +31,8 @@ const { Entity } = require('./Entity'); const uuidv4 = require('uuid').v4; const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; +const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse'; +const APP_UID_ALIAS_TTL_SECONDS = 60 * 60 * 24 * 90; const indexUrlUniquenessExemptionCandidates = [ 'https://dev-center.puter.com/coming-soon', ]; @@ -352,7 +354,25 @@ class AppES extends BaseES { }); const svc_appInformation = this.context.get('services').get('app-information'); if ( svc_appInformation?.delete_app ) { - await svc_appInformation.delete_app(extra.joined_source_app_uid); + await svc_appInformation.delete_app(extra.joined_source_app_uid, undefined, { + preserveCanonicalUidAlias: true, + }); + } + } + + if ( typeof extra.joined_requested_name === 'string' && extra.joined_requested_name.trim() ) { + const renameResult = await this.apply_joined_requested_name_({ + canonicalUid: await full_entity.get('uid'), + requestedName: extra.joined_requested_name, + }); + if ( renameResult ) { + const svc_event = this.context.get('services').get('event'); + await svc_event.emit('app.rename', { + app_uid: await full_entity.get('uid'), + old_name: renameResult.oldName, + new_name: renameResult.newName, + }); + await full_entity.set('name', renameResult.newName); } } @@ -682,6 +702,23 @@ class AppES extends BaseES { return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`; }, + build_canonical_app_uid_alias_reverse_key_ (canonicalAppUid) { + return `${APP_UID_ALIAS_REVERSE_KEY_PREFIX}:${canonicalAppUid}`; + }, + + normalize_canonical_alias_uid_list_ (value) { + if ( ! Array.isArray(value) ) return []; + const normalizedList = []; + const seen = new Set(); + for ( const item of value ) { + if ( typeof item !== 'string' || !item ) continue; + if ( seen.has(item) ) continue; + seen.add(item); + normalizedList.push(item); + } + return normalizedList; + }, + async read_canonical_app_uid_alias_ (oldAppUid) { if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null; @@ -715,11 +752,27 @@ class AppES extends BaseES { if ( !suService || typeof suService.sudo !== 'function' ) return; const key = this.build_canonical_app_uid_alias_key_(oldAppUid); + const reverseKey = this.build_canonical_app_uid_alias_reverse_key_(canonicalAppUid); + const expireAt = Math.floor(Date.now() / 1000) + APP_UID_ALIAS_TTL_SECONDS; try { - await suService.sudo(() => kvStore.set({ - key, - value: canonicalAppUid, - })); + await suService.sudo(async () => { + const reverseValue = await kvStore.get({ key: reverseKey }); + const reverseAliases = this.normalize_canonical_alias_uid_list_(reverseValue); + if ( ! reverseAliases.includes(oldAppUid) ) { + reverseAliases.push(oldAppUid); + } + + await kvStore.set({ + key, + value: canonicalAppUid, + expireAt, + }); + await kvStore.set({ + key: reverseKey, + value: reverseAliases, + expireAt, + }); + }); } catch { // Alias writes are best-effort. } @@ -794,6 +847,9 @@ class AppES extends BaseES { && requestedName !== undefined ) { entity.del('name'); + if ( typeof requestedName === 'string' && requestedName.trim() ) { + extra.joined_requested_name = requestedName.trim(); + } } if ( sourceUid && targetUid && sourceUid !== targetUid ) { @@ -805,6 +861,40 @@ class AppES extends BaseES { extra.old_entity = old_entity; }, + async apply_joined_requested_name_ ({ canonicalUid, requestedName }) { + if ( typeof canonicalUid !== 'string' || !canonicalUid ) return null; + if ( typeof requestedName !== 'string' || !requestedName.trim() ) return null; + const normalizedName = requestedName.trim(); + + const currentRows = await this.db.read( + 'SELECT name FROM apps WHERE uid = ? LIMIT 1', + [canonicalUid], + ); + const currentName = currentRows?.[0]?.name; + if ( typeof currentName !== 'string' ) return null; + if ( currentName === normalizedName ) return null; + + const conflictRows = await this.db.read( + 'SELECT uid FROM apps WHERE name = ? AND uid != ? LIMIT 1', + [normalizedName, canonicalUid], + ); + if ( conflictRows.length > 0 ) { + throw APIError.create('app_name_already_in_use', null, { + name: normalizedName, + }); + } + + await this.db.write( + 'UPDATE apps SET name = ? WHERE uid = ? LIMIT 1', + [normalizedName, canonicalUid], + ); + + return { + oldName: currentName, + newName: normalizedName, + }; + }, + async is_origin_bootstrap_app_entity_ (entity) { if ( ! entity ) return false; const uid = await entity.get('uid'); diff --git a/src/backend/vitest.config.ts b/src/backend/vitest.config.ts index 94b1ca1e6..ce510d122 100644 --- a/src/backend/vitest.config.ts +++ b/src/backend/vitest.config.ts @@ -2,13 +2,22 @@ import { loadEnv } from 'vite'; import { defineConfig } from 'vitest/config'; +const isCi = process.env.CI === 'true'; + export default defineConfig(({ mode }) => ({ test: { globals: true, + maxWorkers: isCi ? 2 : undefined, + minWorkers: isCi ? 1 : undefined, coverage: { provider: 'v8', - reporter: ['text', 'json', 'json-summary', 'html', 'lcov'], - include: ['src/**/*.{js,mjs,ts,mts}'], + reporter: isCi + ? ['json', 'json-summary', 'lcov'] + : ['text', 'json', 'json-summary', 'html', 'lcov'], + processingConcurrency: isCi ? 2 : undefined, + excludeAfterRemap: true, + // Keep coverage focused on executed files to avoid high-memory + // uncovered-file remapping in CI. exclude: [ 'src/**/types/**', 'src/**/constants/**', @@ -17,6 +26,10 @@ export default defineConfig(({ mode }) => ({ 'src/**/*.d.cts', 'src/**/dist/**', 'src/**/*.min.*', + 'src/**/*.bench.{js,mjs,ts,mts}', + 'src/**/*.{test,spec}.{js,mjs,ts,mts}', + 'src/public/**', + 'src/services/worker/template/**', ], }, env: loadEnv(mode, '', 'PUTER_'),