fix: app merging (#2654)

* fix: only set authToken if present for apps

* fix: keep bootstrap in url for app to do whatever

* fix: tests

* fix: app merging

just cleaning up how merging subdomain and canon apps work, namely, persisting data better and making sure its temp alias are deleted when appropriate

* fix: tests oom
This commit is contained in:
Daniel Salazar
2026-03-12 10:18:02 -07:00
committed by GitHub
parent d086e4961c
commit 5505da027d
7 changed files with 245 additions and 20 deletions
+3 -1
View File
@@ -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') != '' }}
@@ -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<void>} 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 = [];
@@ -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(
@@ -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;
}
@@ -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 () => {
+95 -5
View File
@@ -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');
+15 -2
View File
@@ -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_'),