mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-03 16:10:31 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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_'),
|
||||
|
||||
Reference in New Issue
Block a user